@gripforgeai/mcp 0.1.5 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -12
- package/dist/gamekit-deliver-local.js +586 -0
- package/dist/gamekit-tools.js +298 -0
- package/dist/scene-tools.js +79 -0
- package/dist/server-tools.js +143 -0
- package/dist/server.js +1483 -51
- package/dist/vfx-project-tools.js +48 -0
- package/package.json +6 -7
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/** Modular Game Kits tools. Studio and MCP call the same Core HTTP API. */
|
|
2
|
+
import { z } from 'zod/v4';
|
|
3
|
+
export const GAMEKIT_TOOL_NAMES = [
|
|
4
|
+
'gripforge_gamekit_search',
|
|
5
|
+
'gripforge_gamekit_get',
|
|
6
|
+
'gripforge_gamekit_install',
|
|
7
|
+
'gripforge_gamekit_remove',
|
|
8
|
+
'gripforge_gamekit_configure',
|
|
9
|
+
'gripforge_gamekit_dependencies',
|
|
10
|
+
'gripforge_gamekit_update',
|
|
11
|
+
'gripforge_gamekit_deliver',
|
|
12
|
+
'gripforge_game_capabilities',
|
|
13
|
+
'gripforge_game_project',
|
|
14
|
+
'gripforge_game_play_url',
|
|
15
|
+
];
|
|
16
|
+
const enc = (v) => encodeURIComponent(String(v));
|
|
17
|
+
const HOSTED_DELIVERY_NOTE = 'This hosted endpoint cannot write into your project: write bundle.files following plan.actions in order, or use npx @gripforgeai/mcp with gripforge_gamekit_deliver_local { project_dir }, or the editor bridge.';
|
|
18
|
+
export function registerGameKitTools(register, options, schema = z) {
|
|
19
|
+
const workspace = {
|
|
20
|
+
workspace_id: schema
|
|
21
|
+
.string()
|
|
22
|
+
.max(100)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Workspace authorized by the current key. Never infer another user’s workspace.'),
|
|
25
|
+
};
|
|
26
|
+
const projectId = schema.string().min(4).max(80).describe('Game Kit project id (gkp_…). Not a dedicated-server project (gpj_…) nor a Library item.');
|
|
27
|
+
const kitId = schema
|
|
28
|
+
.string()
|
|
29
|
+
.min(2)
|
|
30
|
+
.max(120)
|
|
31
|
+
.regex(/^[a-z0-9][a-z0-9._-]*$/i)
|
|
32
|
+
.describe('Kit id from the catalogue, dotted (e.g. vehicle.driveable, mission.objectives).');
|
|
33
|
+
const range = schema.string().max(80).optional().describe('Semver range to satisfy (e.g. ^1.2.0, 1.x). Default: latest compatible version.');
|
|
34
|
+
const config = schema
|
|
35
|
+
.record(schema.string(), schema.unknown())
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('Kit config values, validated against the kit configSchema returned by gripforge_gamekit_get. Merged over the current config.');
|
|
38
|
+
async function api(path, args, method, signal, query) {
|
|
39
|
+
const key = options.getApiKey();
|
|
40
|
+
if (!key)
|
|
41
|
+
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
42
|
+
const { workspace_id, ...body } = args;
|
|
43
|
+
const url = new URL(`${options.apiUrl.replace(/\/$/, '')}/api/v1/${path.replace(/^\//, '')}`);
|
|
44
|
+
if (query) {
|
|
45
|
+
for (const [k, v] of Object.entries(query)) {
|
|
46
|
+
if (v === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
url.searchParams.set(k, String(v));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
'content-type': 'application/json',
|
|
56
|
+
'x-api-key': key,
|
|
57
|
+
'x-gripforge-client': 'mcp',
|
|
58
|
+
...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}),
|
|
59
|
+
},
|
|
60
|
+
...(method === 'GET' || method === 'DELETE' ? {} : { body: JSON.stringify(body) }),
|
|
61
|
+
signal: AbortSignal.any([AbortSignal.timeout(120_000), ...(signal ? [signal] : [])]),
|
|
62
|
+
});
|
|
63
|
+
const data = (await res.json());
|
|
64
|
+
return {
|
|
65
|
+
...(res.ok ? {} : { isError: true }),
|
|
66
|
+
structuredContent: data,
|
|
67
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Game Kits API failed.' }] };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function tool(name, title, description, inputSchema, opts, callback) {
|
|
75
|
+
register(name, {
|
|
76
|
+
title,
|
|
77
|
+
description,
|
|
78
|
+
inputSchema: { ...inputSchema, ...workspace },
|
|
79
|
+
annotations: {
|
|
80
|
+
readOnlyHint: opts.readOnly,
|
|
81
|
+
destructiveHint: Boolean(opts.destructive),
|
|
82
|
+
idempotentHint: opts.readOnly,
|
|
83
|
+
openWorldHint: false,
|
|
84
|
+
},
|
|
85
|
+
}, callback);
|
|
86
|
+
}
|
|
87
|
+
const fail = (text) => ({ isError: true, content: [{ type: 'text', text }] });
|
|
88
|
+
/** Body for project-scoped writes: keep workspace_id (header), drop routing-only fields. */
|
|
89
|
+
const body = (args, ...drop) => {
|
|
90
|
+
const out = { ...args };
|
|
91
|
+
for (const k of ['project_id', 'id', 'action', 'confirm', ...drop])
|
|
92
|
+
delete out[k];
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
tool('gripforge_gamekit_search', 'Search the Game Kit catalogue', 'Start here. Search the modular Game Kit catalogue (vehicle.driveable, mission.objectives, npc.wanted, …) by free text, capability, tag or engine target. Pass project_id to score kits against that project: each hit then carries state { installed, updateAvailable, compatible, conflicts, reason } and kits that fill one of its missing capabilities rank first. Returns { kits, presets }; legacy genre kits appear as legacy.* and are installed with gripforge_kit, not here. Kart Racing is native: compose world.racetrack + vehicle.driveable + race.kart; edit race_tracks through project data. world.lighting adds shared sun/fill, point lights and spots, presets, bounded light budgets and bloom; edit world_lights through project data. Call this BEFORE gripforge_gamekit_install. 0 credits.', {
|
|
96
|
+
q: schema.string().max(200).optional().describe('Free text matched on id, name, description and tags (e.g. "drive a car", "wanted level").'),
|
|
97
|
+
capability: schema.string().max(120).optional().describe('Capability the kit must provide (exact id or prefix, e.g. vehicle.drive).'),
|
|
98
|
+
tag: schema.string().max(60).optional().describe('Tag filter (e.g. vehicle, mission, npc, legacy).'),
|
|
99
|
+
target: schema.enum(['web', 'godot', 'unity', 'unreal']).optional().describe('Engine target the kit must support. Default web.'),
|
|
100
|
+
project_id: projectId.optional().describe('Game Kit project id (gkp_…) to score results against. Default: the workspace’s most recently updated project.'),
|
|
101
|
+
limit: schema.number().int().min(1).max(100).optional().describe('Max kits returned (default 20).'),
|
|
102
|
+
}, { readOnly: true }, (args, extra) => api('gamekits', args, 'GET', extra?.signal, {
|
|
103
|
+
q: typeof args.q === 'string' ? args.q : undefined,
|
|
104
|
+
capability: typeof args.capability === 'string' ? args.capability : undefined,
|
|
105
|
+
tag: typeof args.tag === 'string' ? args.tag : undefined,
|
|
106
|
+
target: typeof args.target === 'string' ? args.target : undefined,
|
|
107
|
+
project: typeof args.project_id === 'string' ? args.project_id : undefined,
|
|
108
|
+
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
109
|
+
}));
|
|
110
|
+
tool('gripforge_gamekit_get', 'Read one Game Kit', 'Read one Game Kit: its manifest (provides / requires capabilities, asset slots, data collections, events), README docs, config JSON schema with defaults, and published versions. Pass with_usage=true to also list the workspace projects that have it installed. Call this BEFORE gripforge_gamekit_configure to know which config keys exist. 0 credits.', {
|
|
111
|
+
id: kitId,
|
|
112
|
+
version: schema.string().max(40).optional().describe('Exact version to read (e.g. 1.0.0). Default: latest.'),
|
|
113
|
+
with_usage: schema.boolean().optional().describe('true to include usedBy: the workspace projects where this kit is installed.'),
|
|
114
|
+
}, { readOnly: true }, (args, extra) => api(`gamekits/${enc(args.id)}`, args, 'GET', extra?.signal, {
|
|
115
|
+
version: typeof args.version === 'string' ? args.version : undefined,
|
|
116
|
+
with: args.with_usage === true ? 'usage' : undefined,
|
|
117
|
+
}));
|
|
118
|
+
tool('gripforge_gamekit_install', 'Install a Game Kit into a project', 'Add a modular gameplay kit to a Game Kit project (gkp_…). Dependencies declared in the kit manifest are resolved and added automatically; the response returns the plan (add / upgrade / keep) and the updated capabilities. Pass dry_run=true to preview without writing. Call gripforge_game_capabilities first to see what the project is missing, gripforge_gamekit_search to find the kit, then gripforge_gamekit_configure to tune it. Legacy genre kits (legacy.*) are not installable here — use gripforge_kit. 0 credits.', {
|
|
119
|
+
project_id: projectId,
|
|
120
|
+
id: kitId,
|
|
121
|
+
range,
|
|
122
|
+
config,
|
|
123
|
+
dry_run: schema.boolean().optional().describe('true to return the install plan without writing the project.'),
|
|
124
|
+
allow_planned: schema.boolean().optional().describe('true to accept kits still marked planned (0.x, no runtime yet). Default false.'),
|
|
125
|
+
}, { readOnly: false }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/kits`, { ...body(args), id: args.id }, 'POST', extra?.signal));
|
|
126
|
+
tool('gripforge_gamekit_remove', 'Uninstall a Game Kit from a project', 'Uninstall a kit from a Game Kit project (gkp_…). Refused with 409 kit_in_use while other installed kits still require one of its capabilities; pass force=true to remove it anyway and prune=true to also drop the dependencies nothing else needs. Returns { removed[], project }. Call gripforge_gamekit_dependencies BEFORE forcing to see who depends on it. 0 credits.', {
|
|
127
|
+
project_id: projectId,
|
|
128
|
+
id: kitId,
|
|
129
|
+
force: schema.boolean().optional().describe('true to remove even when other kits depend on it (their requirements become unresolved).'),
|
|
130
|
+
prune: schema.boolean().optional().describe('true to also remove dependencies that no remaining kit requires.'),
|
|
131
|
+
}, { readOnly: false, destructive: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/kits/${enc(args.id)}`, args, 'DELETE', extra?.signal, {
|
|
132
|
+
force: args.force === true ? 1 : undefined,
|
|
133
|
+
prune: args.prune === true ? 1 : undefined,
|
|
134
|
+
}));
|
|
135
|
+
tool('gripforge_gamekit_configure', 'Configure an installed Game Kit', 'Tune an installed kit: merge config values validated against the kit configSchema (read it with gripforge_gamekit_get), or toggle enabled to switch the kit off without uninstalling it. Returns { kit, project } with the new project revision. Call this AFTER gripforge_gamekit_install. 0 credits.', {
|
|
136
|
+
project_id: projectId,
|
|
137
|
+
id: kitId,
|
|
138
|
+
config,
|
|
139
|
+
enabled: schema.boolean().optional().describe('false to disable the kit at runtime while keeping it installed; true to re-enable.'),
|
|
140
|
+
}, { readOnly: false }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/kits/${enc(args.id)}`, body(args), 'PATCH', extra?.signal));
|
|
141
|
+
tool('gripforge_gamekit_dependencies', 'Dependency graph of a project or kit', 'Dependency graph of a Game Kit project: nodes (installed kits with version, provides, requires), edges labelled by capability, conflicts and unresolved requirements. Pass project_id for the installed graph; pass a kit id instead to read the declared requires / provides tree of a catalogue kit before installing it. Call this BEFORE gripforge_gamekit_remove with force. 0 credits.', {
|
|
142
|
+
project_id: projectId.optional().describe('Game Kit project id (gkp_…). Default: the workspace’s most recently updated project when id is not given.'),
|
|
143
|
+
id: kitId.optional().describe('Catalogue kit id to inspect instead of a project (returns its manifest tree).'),
|
|
144
|
+
}, { readOnly: true }, (args, extra) => {
|
|
145
|
+
if (typeof args.id === 'string' && typeof args.project_id !== 'string')
|
|
146
|
+
return api(`gamekits/${enc(args.id)}`, args, 'GET', extra?.signal);
|
|
147
|
+
if (typeof args.project_id !== 'string')
|
|
148
|
+
return Promise.resolve(fail('Pass project_id (gkp_…) or a kit id.'));
|
|
149
|
+
return api(`gamekit-projects/${enc(args.project_id)}/dependencies`, args, 'GET', extra?.signal);
|
|
150
|
+
});
|
|
151
|
+
tool('gripforge_gamekit_update', 'Check or apply Game Kit updates', 'Check or apply kit updates in a Game Kit project. Dry-run by default: returns the plan (from / to version, breaking changes, migrations to run) without writing. Pass apply=true to write the new lockfile and run the migrations; range narrows the target version (e.g. ^1.2.0). Omit id to check every installed kit in one call. Call gripforge_gamekit_get on the target version to read its manifest first. 0 credits.', {
|
|
152
|
+
project_id: projectId,
|
|
153
|
+
id: kitId.optional().describe('Installed kit to update. Omit to check every installed kit.'),
|
|
154
|
+
range,
|
|
155
|
+
apply: schema.boolean().optional().describe('true to apply the update (new lockfile + migrations). Default false = plan only.'),
|
|
156
|
+
}, { readOnly: false }, async (args, extra) => {
|
|
157
|
+
const project = enc(args.project_id);
|
|
158
|
+
const payload = body(args);
|
|
159
|
+
if (typeof args.id === 'string')
|
|
160
|
+
return api(`gamekit-projects/${project}/kits/${enc(args.id)}/update`, payload, 'POST', extra?.signal);
|
|
161
|
+
const detail = await api(`gamekit-projects/${project}`, args, 'GET', extra?.signal);
|
|
162
|
+
if (detail.isError)
|
|
163
|
+
return detail;
|
|
164
|
+
const installed = detail.structuredContent?.installed;
|
|
165
|
+
const ids = Array.isArray(installed)
|
|
166
|
+
? installed.map((k) => (typeof k === 'string' ? k : k?.id)).filter((k) => typeof k === 'string')
|
|
167
|
+
: installed && typeof installed === 'object'
|
|
168
|
+
? Object.keys(installed)
|
|
169
|
+
: [];
|
|
170
|
+
const kits = [];
|
|
171
|
+
let isError = false;
|
|
172
|
+
for (const id of ids) {
|
|
173
|
+
const r = await api(`gamekit-projects/${project}/kits/${enc(id)}/update`, payload, 'POST', extra?.signal);
|
|
174
|
+
isError ||= Boolean(r.isError);
|
|
175
|
+
const first = r.content[0];
|
|
176
|
+
kits.push({ id, ...(r.structuredContent ?? { error: first?.type === 'text' ? first.text : 'update failed' }) });
|
|
177
|
+
}
|
|
178
|
+
const data = { project_id: args.project_id, kits };
|
|
179
|
+
return { ...(isError ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
180
|
+
});
|
|
181
|
+
tool('gripforge_gamekit_deliver', 'Deliver Game Kits into an engine project', 'Turn catalogue kits, or the enabled kits of a Game Kit project, into engine files: Godot gets GDScript, scenes and config under gripforge/kits/<kit>/, Unity and Unreal get recipes, web needs nothing. Call with dry_run=true FIRST: it returns the plan (files to add / keep / modify / delete, conflicts, dependencies, post-install steps) and never charges. Pass project_state (the current gripforge/gamekits.lock.json, path → sha256 of the files under gripforge/, engineVersion) so updates keep your edits and detect conflicts. Then call without dry_run to get the bundle. The hosted endpoint cannot write into your project: write bundle.files following plan.actions in order (backup, write, writeBin from bundle.binaries, delete with .uid / .import siblings, writeLock with bundle.lock), or use the npm client with project_dir (gripforge_gamekit_deliver_local), or the editor bridge. A blocked plan answers 409 with the plan: force backs up then overwrites edited files, accept_breaking allows a major update. Credits: the first delivery of a kit major version to an engine costs 1 credit per workspace; re-deliveries, updates within a major, dry runs and the web target are free.', {
|
|
182
|
+
target: schema.enum(['godot', 'unity', 'unreal', 'web']).describe('Engine to deliver to: godot (files), unity / unreal (recipes), web (native, nothing to write).'),
|
|
183
|
+
kits: schema
|
|
184
|
+
.array(schema.object({
|
|
185
|
+
id: kitId,
|
|
186
|
+
version: schema.string().max(40).optional().describe('Exact version; only the latest is deliverable. Default latest.'),
|
|
187
|
+
config: schema.record(schema.string(), schema.unknown()).optional().describe('Kit config, validated against its configSchema.'),
|
|
188
|
+
bindings: schema.record(schema.string(), schema.string()).optional().describe('Asset slot → Library item id (lib_…).'),
|
|
189
|
+
}))
|
|
190
|
+
.max(16)
|
|
191
|
+
.optional()
|
|
192
|
+
.describe('Kits to deliver (at most 16). Omit to deliver the enabled kits of project_id.'),
|
|
193
|
+
project_id: projectId.optional().describe('Game Kit project (gkp_…) whose enabled kits, config and slot bindings are delivered when kits is omitted.'),
|
|
194
|
+
mode: schema.enum(['install', 'update', 'uninstall']).optional().describe('install (default), update to the latest catalogue version, or uninstall (needs project_state.lock).'),
|
|
195
|
+
project_state: schema
|
|
196
|
+
.object({
|
|
197
|
+
engineVersion: schema.string().max(80).optional().describe('Engine version, e.g. 4.3.stable.'),
|
|
198
|
+
lock: schema.record(schema.string(), schema.unknown()).nullable().optional().describe('Current gripforge/gamekits.lock.json content, null when absent.'),
|
|
199
|
+
files: schema.record(schema.string(), schema.string()).optional().describe('path → sha256 hex of every file under gripforge/ and assets/gripforge/ (at most 5000).'),
|
|
200
|
+
})
|
|
201
|
+
.optional()
|
|
202
|
+
.describe('What the engine project contains now. Without it the plan assumes an empty project (add-only).'),
|
|
203
|
+
dry_run: schema.boolean().optional().describe('true → plan only: no bundle, never charged. Do this first.'),
|
|
204
|
+
force: schema.boolean().optional().describe('true → back up then overwrite edited managed files and existing seed files.'),
|
|
205
|
+
accept_breaking: schema.boolean().optional().describe('true → allow a breaking (major) update.'),
|
|
206
|
+
}, { readOnly: false }, async (args, extra) => {
|
|
207
|
+
const kits = Array.isArray(args.kits) ? args.kits : [];
|
|
208
|
+
const state = args.project_state;
|
|
209
|
+
if (!kits.length && typeof args.project_id !== 'string' && !(args.mode === 'uninstall' && state?.lock))
|
|
210
|
+
return fail('Pass kits [{ id }] or project_id (gkp_…).');
|
|
211
|
+
const result = await api('gamekits/deliver', {
|
|
212
|
+
workspace_id: args.workspace_id,
|
|
213
|
+
target: args.target,
|
|
214
|
+
...(kits.length ? { kits } : {}),
|
|
215
|
+
projectId: args.project_id,
|
|
216
|
+
mode: args.mode,
|
|
217
|
+
project: args.project_state,
|
|
218
|
+
dryRun: args.dry_run === true,
|
|
219
|
+
force: args.force === true,
|
|
220
|
+
acceptBreaking: args.accept_breaking === true,
|
|
221
|
+
}, 'POST', extra?.signal);
|
|
222
|
+
const data = result.structuredContent;
|
|
223
|
+
if (result.isError || !data?.bundle)
|
|
224
|
+
return result;
|
|
225
|
+
const withNote = { ...data, note: HOSTED_DELIVERY_NOTE };
|
|
226
|
+
return { structuredContent: withNote, content: [{ type: 'text', text: JSON.stringify(withNote) }] };
|
|
227
|
+
});
|
|
228
|
+
tool('gripforge_game_capabilities', 'Capabilities report of a project', 'Start here for an existing project. Report the capabilities a Game Kit project provides, what is still missing for a goal (a preset id from gripforge_gamekit_search or a comma-separated list of capabilities) and which catalogue kits would fill each gap. Call this BEFORE gripforge_gamekit_install to pick the next kit. 0 credits.', {
|
|
229
|
+
project_id: projectId,
|
|
230
|
+
goal: schema.string().max(400).optional().describe('Preset id (e.g. from gripforge_gamekit_search presets) or comma-separated capabilities to reach. Default: the project’s own preset.'),
|
|
231
|
+
}, { readOnly: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/capabilities`, args, 'GET', extra?.signal, {
|
|
232
|
+
goal: typeof args.goal === 'string' ? args.goal : undefined,
|
|
233
|
+
}));
|
|
234
|
+
tool('gripforge_game_project', 'List, create, read, bind, feed or delete Game Kit projects', 'Manage Game Kit projects (gkp_…): the container holding installed kits, their lockfile, asset bindings and data collections. action=list lists the workspace projects; create needs name and takes a preset (see gripforge_gamekit_search) or an explicit kits map; get returns the full ProjectDetail (installed kits, bindings, capabilities, missing, playUrl); bind maps asset slots to Library items (lib_…, null to clear); data reads a collection or upserts / removes documents validated against the kits’ schemas (replace=true swaps the whole collection); delete needs confirm=true. Call this first to create or pick the project, then gripforge_gamekit_install. 0 credits.', {
|
|
235
|
+
action: schema.enum(['list', 'create', 'get', 'bind', 'data', 'delete']).describe('list | create | get | bind | data | delete.'),
|
|
236
|
+
project_id: projectId.optional().describe('Game Kit project id (gkp_…). Required for get, bind, data and delete.'),
|
|
237
|
+
name: schema.string().min(1).max(120).optional().describe('create: project name.'),
|
|
238
|
+
preset: schema.string().max(80).optional().describe('create: preset id whose default kits are installed (gripforge_gamekit_search returns presets).'),
|
|
239
|
+
kits: schema.record(schema.string(), schema.boolean()).optional().describe('create: explicit kit toggles { "vehicle.driveable": true, … } on top of the preset. Unchecking a required kit fails with toggle_requires.'),
|
|
240
|
+
target: schema.enum(['web', 'godot', 'unity', 'unreal']).optional().describe('create: engine target. Default web.'),
|
|
241
|
+
bindings: schema.record(schema.string(), schema.string().nullable()).optional().describe('bind: { slot: lib_… | null } — Library item per asset slot declared by the installed kits, null clears.'),
|
|
242
|
+
collection: schema.string().max(80).optional().describe('data: collection name declared by an installed kit (e.g. missions, npcs, zones).'),
|
|
243
|
+
documents: schema.array(schema.record(schema.string(), schema.unknown())).max(500).optional().describe('data: documents to upsert (each with its id), validated against the kit schema.'),
|
|
244
|
+
remove: schema.array(schema.string().max(120)).max(500).optional().describe('data: document ids to remove.'),
|
|
245
|
+
replace: schema.boolean().optional().describe('data: true to replace the whole collection with documents.'),
|
|
246
|
+
confirm: schema.boolean().optional().describe('delete: must be true. The project, its revisions and data are removed.'),
|
|
247
|
+
}, { readOnly: false }, (args, extra) => {
|
|
248
|
+
const action = String(args.action);
|
|
249
|
+
if (action === 'list')
|
|
250
|
+
return api('gamekit-projects', args, 'GET', extra?.signal);
|
|
251
|
+
if (action === 'create') {
|
|
252
|
+
if (typeof args.name !== 'string' || !args.name.trim())
|
|
253
|
+
return Promise.resolve(fail('create needs name.'));
|
|
254
|
+
return api('gamekit-projects', body(args, 'bindings', 'collection', 'documents', 'remove', 'replace'), 'POST', extra?.signal);
|
|
255
|
+
}
|
|
256
|
+
if (typeof args.project_id !== 'string')
|
|
257
|
+
return Promise.resolve(fail(`${action} needs project_id (gkp_…).`));
|
|
258
|
+
const project = enc(args.project_id);
|
|
259
|
+
if (action === 'get')
|
|
260
|
+
return api(`gamekit-projects/${project}`, args, 'GET', extra?.signal);
|
|
261
|
+
if (action === 'bind') {
|
|
262
|
+
if (!args.bindings || typeof args.bindings !== 'object')
|
|
263
|
+
return Promise.resolve(fail('bind needs bindings { slot: lib_… | null }.'));
|
|
264
|
+
return api(`gamekit-projects/${project}/bindings`, { workspace_id: args.workspace_id, bindings: args.bindings }, 'PATCH', extra?.signal);
|
|
265
|
+
}
|
|
266
|
+
if (action === 'data') {
|
|
267
|
+
if (typeof args.collection !== 'string')
|
|
268
|
+
return Promise.resolve(fail('data needs collection.'));
|
|
269
|
+
const writes = Array.isArray(args.documents) || Array.isArray(args.remove) || args.replace === true;
|
|
270
|
+
if (!writes)
|
|
271
|
+
return api(`gamekit-projects/${project}/data`, args, 'GET', extra?.signal, { collection: args.collection });
|
|
272
|
+
return api(`gamekit-projects/${project}/data`, { workspace_id: args.workspace_id, collection: args.collection, upsert: args.documents, remove: args.remove, replace: args.replace }, 'PATCH', extra?.signal);
|
|
273
|
+
}
|
|
274
|
+
if (action === 'delete') {
|
|
275
|
+
if (args.confirm !== true)
|
|
276
|
+
return Promise.resolve(fail('delete needs confirm=true.'));
|
|
277
|
+
return api(`gamekit-projects/${project}`, args, 'DELETE', extra?.signal, { confirm: 1 });
|
|
278
|
+
}
|
|
279
|
+
return Promise.resolve(fail(`Unknown action ${action}.`));
|
|
280
|
+
});
|
|
281
|
+
tool('gripforge_game_play_url', 'Play URL of a Game Kit project', 'Get the browser play URL of a Game Kit project so a human (or a screenshot step) can run its current revision in the web runtime. Reads the project bundle and returns { url, absolute }; absolute is built on the GripForge origin of this key. Call it AFTER gripforge_gamekit_install or gripforge_game_project bind to check the result live. 0 credits.', { project_id: projectId }, { readOnly: true }, async (args, extra) => {
|
|
282
|
+
const res = await api(`gamekit-projects/${enc(args.project_id)}/bundle`, args, 'GET', extra?.signal);
|
|
283
|
+
if (res.isError)
|
|
284
|
+
return res;
|
|
285
|
+
const playUrl = res.structuredContent?.playUrl;
|
|
286
|
+
if (typeof playUrl !== 'string' || !playUrl)
|
|
287
|
+
return fail('Bundle has no playUrl yet — install at least one kit first.');
|
|
288
|
+
let absolute = playUrl;
|
|
289
|
+
try {
|
|
290
|
+
absolute = new URL(playUrl, options.apiUrl).href;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* keep the raw value */
|
|
294
|
+
}
|
|
295
|
+
const data = { url: playUrl, absolute };
|
|
296
|
+
return { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
/** UI and agents call the same scene commands, access checks and revision store. */
|
|
3
|
+
export function registerSceneTools(register, options, schema = z) {
|
|
4
|
+
const identifier = schema.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}$/);
|
|
5
|
+
const record = schema.record(schema.string(), schema.unknown());
|
|
6
|
+
const revision = schema.number().int().positive();
|
|
7
|
+
const workspace = { workspace_id: schema.string().max(100).optional().describe('Workspace authorized by the current key. Returned scene links open this workspace.') };
|
|
8
|
+
async function call(path, args, method, signal) {
|
|
9
|
+
const key = options.getApiKey();
|
|
10
|
+
if (!key)
|
|
11
|
+
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
12
|
+
const { workspace_id, id: _id, version: _version, include_preview, ...body } = args;
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/${path}`, {
|
|
15
|
+
method, headers: { 'content-type': 'application/json', 'x-api-key': key, 'x-gripforge-client': 'mcp', ...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}) },
|
|
16
|
+
...(method === 'GET' ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.any([AbortSignal.timeout(60_000), ...(signal ? [signal] : [])]),
|
|
17
|
+
});
|
|
18
|
+
const data = await response.json();
|
|
19
|
+
if (typeof data.studio_url === 'string')
|
|
20
|
+
data.studio_url = new URL(data.studio_url, options.apiUrl).href;
|
|
21
|
+
const job = data.job;
|
|
22
|
+
for (const result of [data.result, job?.result])
|
|
23
|
+
if (result && typeof result === 'object' && typeof result.studio_url === 'string')
|
|
24
|
+
result.studio_url = new URL(String(result.studio_url), options.apiUrl).href;
|
|
25
|
+
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') });
|
|
35
|
+
}
|
|
36
|
+
return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Scene API failed.' }] };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
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);
|
|
43
|
+
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
|
+
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
|
+
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.', {
|
|
46
|
+
prompt: schema.string().min(3).max(2000), name: schema.string().max(100).optional(),
|
|
47
|
+
pieces: schema.array(schema.enum(['helmet', 'chest', 'belt', 'pauldron', 'bracer', 'glove', 'thigh', 'greave', 'boot', 'upperarm', 'gorget', 'tasset', 'undersuit'])).min(1).max(13).optional(),
|
|
48
|
+
coverage: schema.enum(['modular', 'full']).optional(), textile_color: schema.string().regex(/^#[a-fA-F0-9]{6}$/).optional(),
|
|
49
|
+
concept_item: identifier.optional(), source_kit: identifier.optional(), character_items: schema.array(identifier).max(3).optional(),
|
|
50
|
+
polycount: schema.number().int().min(3000).max(30000).optional(), idempotency_key: schema.string().min(8).max(160).optional(),
|
|
51
|
+
}, false, (args, extra) => call('armor-kits', args, 'POST', extra?.signal));
|
|
52
|
+
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
|
+
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
|
+
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));
|
|
56
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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));
|
|
61
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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));
|
|
68
|
+
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
|
+
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
|
+
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));
|
|
71
|
+
tool('gripforge_scene_asset_review', 'Review an immutable asset', 'Create a shared test scene and queue an independent review of real GripForge captures. Supports meshes, textures and effects. Never promotes automatically.', { assetId: identifier, revisionId: identifier, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scene-assets/${args.assetId}/${args.revisionId}/review`, args, 'POST', extra?.signal));
|
|
72
|
+
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
|
+
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
|
+
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));
|
|
76
|
+
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
|
+
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
|
+
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));
|
|
79
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Dedicated game-server tools. Dashboard and MCP call the same Core HTTP API. */
|
|
2
|
+
import { z } from 'zod/v4';
|
|
3
|
+
export const SERVER_TOOL_NAMES = [
|
|
4
|
+
'gripforge_project_get',
|
|
5
|
+
'gripforge_server_list',
|
|
6
|
+
'gripforge_server_get',
|
|
7
|
+
'gripforge_server_create',
|
|
8
|
+
'gripforge_server_deploy',
|
|
9
|
+
'gripforge_server_start',
|
|
10
|
+
'gripforge_server_stop',
|
|
11
|
+
'gripforge_server_restart',
|
|
12
|
+
'gripforge_server_scale',
|
|
13
|
+
'gripforge_server_delete',
|
|
14
|
+
'gripforge_server_logs',
|
|
15
|
+
'gripforge_server_metrics',
|
|
16
|
+
'gripforge_server_events',
|
|
17
|
+
'gripforge_build_list',
|
|
18
|
+
'gripforge_build_get',
|
|
19
|
+
'gripforge_build_deploy',
|
|
20
|
+
'gripforge_build_rollback',
|
|
21
|
+
'gripforge_players_list',
|
|
22
|
+
'gripforge_player_get',
|
|
23
|
+
'gripforge_player_kick',
|
|
24
|
+
'gripforge_player_ban',
|
|
25
|
+
'gripforge_game_broadcast',
|
|
26
|
+
'gripforge_world_list',
|
|
27
|
+
'gripforge_world_deploy',
|
|
28
|
+
'gripforge_world_backup',
|
|
29
|
+
'gripforge_world_restore',
|
|
30
|
+
'gripforge_match_list',
|
|
31
|
+
'gripforge_match_get',
|
|
32
|
+
'gripforge_match_create',
|
|
33
|
+
'gripforge_match_stop',
|
|
34
|
+
];
|
|
35
|
+
export function registerServerTools(register, options, schema = z) {
|
|
36
|
+
const workspace = {
|
|
37
|
+
workspace_id: schema
|
|
38
|
+
.string()
|
|
39
|
+
.max(100)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe('Workspace authorized by the current key. Never infer another user’s workspace.'),
|
|
42
|
+
};
|
|
43
|
+
const serverId = schema.string().min(4).max(80).describe('Game server id (srv_…)');
|
|
44
|
+
const playerId = schema.string().min(4).max(80).describe('Player session id (ses_…)');
|
|
45
|
+
const buildId = schema.string().min(4).max(80).describe('Game build id (bld_…)');
|
|
46
|
+
const projectId = schema.string().min(4).max(80).describe('Game project id (gpj_…)');
|
|
47
|
+
const matchId = schema.string().min(4).max(80).describe('Match id (mtc_…)');
|
|
48
|
+
async function api(path, args, method, signal, query) {
|
|
49
|
+
const key = options.getApiKey();
|
|
50
|
+
if (!key)
|
|
51
|
+
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
52
|
+
const { workspace_id, ...body } = args;
|
|
53
|
+
const url = new URL(`${options.apiUrl.replace(/\/$/, '')}/api/v1/${path.replace(/^\//, '')}`);
|
|
54
|
+
if (query) {
|
|
55
|
+
for (const [k, v] of Object.entries(query)) {
|
|
56
|
+
if (v === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
url.searchParams.set(k, String(v));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(url, {
|
|
63
|
+
method,
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type': 'application/json',
|
|
66
|
+
'x-api-key': key,
|
|
67
|
+
'x-gripforge-client': 'mcp',
|
|
68
|
+
...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}),
|
|
69
|
+
},
|
|
70
|
+
...(method === 'GET' || method === 'DELETE' ? {} : { body: JSON.stringify(body) }),
|
|
71
|
+
signal: AbortSignal.any([AbortSignal.timeout(120_000), ...(signal ? [signal] : [])]),
|
|
72
|
+
});
|
|
73
|
+
const data = (await res.json());
|
|
74
|
+
return {
|
|
75
|
+
...(res.ok ? {} : { isError: true }),
|
|
76
|
+
structuredContent: data,
|
|
77
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Server API failed.' }] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function tool(name, title, description, inputSchema, opts, callback) {
|
|
85
|
+
register(name, {
|
|
86
|
+
title,
|
|
87
|
+
description,
|
|
88
|
+
inputSchema: { ...inputSchema, ...workspace },
|
|
89
|
+
annotations: {
|
|
90
|
+
readOnlyHint: opts.readOnly,
|
|
91
|
+
destructiveHint: Boolean(opts.destructive),
|
|
92
|
+
idempotentHint: opts.readOnly,
|
|
93
|
+
openWorldHint: false,
|
|
94
|
+
},
|
|
95
|
+
}, callback);
|
|
96
|
+
}
|
|
97
|
+
tool('gripforge_project_get', 'Get a game project', 'Read a dedicated-server project (gpj_…) in the authenticated workspace. Not a Library asset.', { id: projectId }, { readOnly: true }, (args, extra) => api(`game-projects/${encodeURIComponent(String(args.id))}`, args, 'GET', extra?.signal));
|
|
98
|
+
tool('gripforge_server_list', 'List game servers', 'List dedicated game servers in the workspace with current status and latest instance.', {}, { readOnly: true }, (args, extra) => api('servers', args, 'GET', extra?.signal));
|
|
99
|
+
tool('gripforge_server_get', 'Get a game server', 'Read one dedicated server, its instance, engine/networking detection and integration status.', { id: serverId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}`, args, 'GET', extra?.signal));
|
|
100
|
+
tool('gripforge_server_create', 'Create a game server', 'Create a dedicated server record. Implemented: Unity+FishNet (Docker demo) and Godot+ENet (real Godot Linux headless in Docker). Unreal returns unsupported. Does not start the process until deploy.', {
|
|
101
|
+
name: schema.string().min(1).max(80),
|
|
102
|
+
engine: schema.enum(['unity', 'unreal', 'godot', 'custom']).optional(),
|
|
103
|
+
networking: schema.enum(['fishnet', 'ngo', 'mirror', 'photon', 'enet', 'custom', 'unknown']).optional(),
|
|
104
|
+
environment: schema.enum(['production', 'staging', 'development']).optional(),
|
|
105
|
+
region: schema.string().max(40).optional(),
|
|
106
|
+
maxPlayers: schema.number().int().min(1).max(200).optional(),
|
|
107
|
+
gamePort: schema.number().int().min(1).max(65535).optional(),
|
|
108
|
+
sourceKind: schema.enum(['project', 'git', 'upload', 'docker']).optional(),
|
|
109
|
+
image: schema.string().max(200).optional(),
|
|
110
|
+
hints: schema.string().max(2000).optional(),
|
|
111
|
+
demo: schema.boolean().optional(),
|
|
112
|
+
}, { readOnly: false }, (args, extra) => api('servers', args, 'POST', extra?.signal));
|
|
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
|
+
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));
|
|
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
|
+
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
|
+
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 }));
|
|
119
|
+
tool('gripforge_server_logs', 'Read server logs', 'Stdout/stderr from the runtime provider (Docker logs). Not stored in SQL.', { id: serverId, tail: schema.number().int().min(20).max(2000).optional() }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/logs`, args, 'GET', extra?.signal, {
|
|
120
|
+
tail: typeof args.tail === 'number' ? args.tail : 200,
|
|
121
|
+
}));
|
|
122
|
+
tool('gripforge_server_metrics', 'Read server metrics', 'CPU, memory, network counters and player count from the metrics provider.', { id: serverId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/metrics`, args, 'GET', extra?.signal));
|
|
123
|
+
tool('gripforge_server_events', 'Read server events', 'Activity feed: server.created, server.ready, player.joined, match.started, …', { id: serverId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/events`, args, 'GET', extra?.signal));
|
|
124
|
+
tool('gripforge_build_list', 'List dedicated builds', 'List game_builds (not Godot kit zips at /api/v1/builds).', {}, { readOnly: true }, (args, extra) => api('game-builds', args, 'GET', extra?.signal));
|
|
125
|
+
tool('gripforge_build_get', 'Get a dedicated build', 'Read one game build. Upload/deploy of Linux dedicated artifacts is not implemented yet.', { id: buildId }, { readOnly: true }, (args, extra) => api(`game-builds/${encodeURIComponent(String(args.id))}`, args, 'GET', extra?.signal));
|
|
126
|
+
tool('gripforge_build_deploy', 'Deploy a build', 'Not implemented. Returns 501 until artifact upload exists.', { id: buildId }, { readOnly: false }, (args, extra) => api(`game-builds/${encodeURIComponent(String(args.id))}/deploy`, args, 'POST', extra?.signal));
|
|
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
|
+
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
|
+
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));
|
|
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
|
+
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
|
+
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
|
|
134
|
+
? api(`servers/${encodeURIComponent(String(args.id))}/worlds`, args, 'GET', extra?.signal)
|
|
135
|
+
: api('game-worlds', args, 'GET', extra?.signal));
|
|
136
|
+
tool('gripforge_world_deploy', 'Deploy a world', 'Not implemented. Returns 501.', { id: serverId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/worlds`, args, 'POST', extra?.signal));
|
|
137
|
+
tool('gripforge_world_backup', 'Backup a world', 'Not implemented. Returns 501. Mutating.', { id: serverId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/backups`, args, 'POST', extra?.signal));
|
|
138
|
+
tool('gripforge_world_restore', 'Restore a backup', 'HIGH IMPACT. Not implemented. Returns 501.', { id: serverId }, { readOnly: false, destructive: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/restore`, args, 'POST', extra?.signal));
|
|
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
|
+
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
|
+
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));
|
|
143
|
+
}
|