@simpligen/mcp 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +455 -435
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simpligen/mcp",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for SimpliGen -- drive local/cloud AI image & video generation from an agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/tools.js CHANGED
@@ -1,435 +1,455 @@
1
- // packages/mcp-server/src/tools.js
2
- // Registers SimpliGen MCP tools on an McpServer-shaped object. Each tool calls a
3
- // ControlApiClient method and returns the JSON result as text content; client
4
- // errors become an isError tool result (so the agent sees the errorCode/message).
5
- import { z } from 'zod';
6
- import { buildResultImageContent } from './resultImage.js';
7
-
8
- export const TOOL_NAMES = [
9
- 'list_capabilities', 'get_status', 'list_projects', 'create_project',
10
- 'upload_file', 'generate', 'list_loras', 'get_job', 'wait_for_result', 'list_jobs',
11
- 'cancel_job', 'prepare_preset', 'get_result_image',
12
- 'list_characters', 'get_character', 'list_character_features', 'list_character_presets',
13
- 'remove_character', 'create_character', 'generate_character_image', 'generate_character_video',
14
- 'generate_character_frame', 'animate_character', 'set_character_preset',
15
- 'enhance_character_media', 'regenerate_character_base',
16
- 'list_recipe_blocks', 'compose_recipe', 'validate_recipe', 'list_recipes', 'get_recipe', 'publish_recipe',
17
- 'run_recipe', 'get_recipe_run', 'wait_for_recipe_run', 'list_recipe_runs', 'cancel_recipe_run',
18
- 'list_subjects', 'list_products', 'create_product', 'remove_product',
19
- ];
20
-
21
- const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
22
- const fail = (err) => ({ isError: true, content: [{ type: 'text', text: `${err.errorCode ? err.errorCode + ': ' : ''}${err.message || String(err)}` }] });
23
- const wrap = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } };
24
-
25
- export function registerTools(server, client) {
26
- server.registerTool('list_capabilities',
27
- { title: 'List capabilities', description: 'List every SimpliGen preset the agent can generate with (image/video), its inputs/options, whether it is installed locally (localReady) and cloud-eligible + credit cost.', inputSchema: {} },
28
- wrap(() => client.getCapabilities()));
29
-
30
- server.registerTool('get_status',
31
- { title: 'Get status', description: 'Engine running state, whether cloud is connected, credit balance, and the active project.', inputSchema: {} },
32
- wrap(() => client.getStatus()));
33
-
34
- server.registerTool('list_projects',
35
- { title: 'List projects', description: 'List projects (output containers).', inputSchema: {} },
36
- wrap(() => client.listProjects()));
37
-
38
- server.registerTool('create_project',
39
- { title: 'Create project', description: 'Create a new project (output container).', inputSchema: { name: z.string().describe('Project name') } },
40
- wrap(({ name }) => client.createProject(name)));
41
-
42
- server.registerTool('upload_file',
43
- { title: 'Upload a file', description: 'Upload a local image/video file and get an opaque handle to pass as image/referenceImages/video in generate.', inputSchema: { path: z.string().describe('Absolute local file path') } },
44
- wrap(({ path }) => client.uploadFile(path)));
45
-
46
- server.registerTool('generate',
47
- {
48
- title: 'Generate', description: 'Start an image or video generation. Inputs (image/referenceImages/video) are handles from upload_file. backend: local | cloud | auto. Returns a jobId; poll with wait_for_result or get_job.',
49
- inputSchema: {
50
- presetId: z.string(), mediaType: z.enum(['image', 'video']), prompt: z.string(),
51
- packId: z.string().optional(), backend: z.enum(['local', 'cloud', 'auto']).optional(),
52
- negativePrompt: z.string().optional(), project: z.string().optional(),
53
- image: z.string().optional().describe('upload_file handle'),
54
- referenceImages: z.array(z.string()).optional().describe('upload_file handles'),
55
- video: z.string().optional().describe('upload_file handle (driving video)'),
56
- loras: z.array(z.object({ name: z.string().describe('LoRA display name from list_loras'), weight: z.number().optional().describe('Strength; defaults to the LoRA default') })).optional().describe('LoRAs to apply. Local backend only, and only for presets where inputs.supportsLoras is true. Use the LoRA trigger words in the prompt so it activates.'),
57
- options: z.object({ resolution: z.string().optional(), aspectRatio: z.string().optional().describe('Aspect ratio, e.g. "1:1", "16:9", "9:16" (portrait/vertical), "4:3", "3:4". See list_capabilities -> options.aspectRatio for the values each preset accepts. Defaults to 1:1 for images (16:9 for video) if omitted.'), durationSeconds: z.number().optional(), steps: z.number().optional(), cfg: z.number().optional(), seed: z.number().optional() }).optional(),
58
- },
59
- },
60
- wrap((args) => client.generate(args)));
61
-
62
- server.registerTool('list_loras',
63
- { title: 'List LoRAs', description: 'List the installed user LoRAs (name, base model, trigger words) so you can pass them in generate -> loras.', inputSchema: {} },
64
- wrap(() => client.listLoras()));
65
-
66
- server.registerTool('get_job',
67
- { title: 'Get job', description: 'Status + result of a generation job. resultPath is the local file when completed.', inputSchema: { jobId: z.string() } },
68
- wrap(({ jobId }) => client.getJob(jobId)));
69
-
70
- server.registerTool('list_jobs',
71
- { title: 'List jobs', description: 'Recent generation jobs.', inputSchema: {} },
72
- wrap(() => client.listJobs()));
73
-
74
- server.registerTool('cancel_job',
75
- { title: 'Cancel job', description: 'Cancel a queued/running job.', inputSchema: { jobId: z.string() } },
76
- wrap(({ jobId }) => client.cancelJob(jobId)));
77
-
78
- server.registerTool('prepare_preset',
79
- { title: 'Prepare preset', description: 'Start downloading a local preset\'s models (so it becomes localReady). Poll list_capabilities for localReady.', inputSchema: { presetId: z.string(), mediaType: z.enum(['image', 'video']), packId: z.string().optional() } },
80
- wrap((args) => client.preparePreset(args)));
81
-
82
- // Convenience: poll get_job until terminal or timeout (no extra endpoint).
83
- server.registerTool('wait_for_result',
84
- { title: 'Wait for result', description: 'Block until a job completes or fails (or the timeout). Returns the final job (resultPath when completed).', inputSchema: { jobId: z.string(), timeoutSeconds: z.number().optional(), pollMs: z.number().optional() } },
85
- wrap(async ({ jobId, timeoutSeconds = 300, pollMs = 2000 }) => {
86
- const deadline = Date.now() + timeoutSeconds * 1000;
87
- // eslint-disable-next-line no-constant-condition
88
- while (true) {
89
- const { job } = await client.getJob(jobId);
90
- if (!job || job.status === 'completed' || job.status === 'failed') return job;
91
- if (Date.now() >= deadline) return { ...job, note: 'timeout_still_running' };
92
- await new Promise((r) => setTimeout(r, pollMs));
93
- }
94
- }));
95
-
96
- // Return a completed job's result image inline (an MCP image block) so the
97
- // agent can actually display it. Images only; video/unreadable -> path note.
98
- server.registerTool('get_result_image',
99
- { title: 'Get result image', description: 'Return a completed job\'s result image inline as a viewable image so it can be displayed in chat. Large images are re-encoded to a full-resolution WebP preview that fits the client size cap; the uncompressed original file path is always included in the caption. Video results return the file path instead. Call after the job is completed.', inputSchema: { jobId: z.string(), maxBytes: z.number().optional().describe('Inline byte budget before re-encoding/resizing (default ~700KB raw)') } },
100
- async ({ jobId, maxBytes }) => {
101
- try {
102
- const { job } = await client.getJob(jobId);
103
- return await buildResultImageContent(job, { budgetBytes: maxBytes });
104
- } catch (e) {
105
- return fail(e);
106
- }
107
- });
108
-
109
- // ---- Character Studio tools ----
110
-
111
- server.registerTool('list_characters',
112
- { title: 'List characters', description: 'List all saved characters in Character Studio.', inputSchema: {} },
113
- wrap(() => client.listCharacters()));
114
-
115
- server.registerTool('get_character',
116
- { title: 'Get character', description: 'Get a single character by ID, including its current base portrait and identity features.', inputSchema: { characterId: z.string() } },
117
- wrap(({ characterId }) => client.getCharacter(characterId)));
118
-
119
- server.registerTool('list_character_features',
120
- { title: 'List character features', description: 'List available feature categories and their options (e.g. hairColor, eyeColor) for use with create_character identity.', inputSchema: {} },
121
- wrap(() => client.listCharacterFeatures()));
122
-
123
- server.registerTool('list_character_presets',
124
- { title: 'List character presets', description: 'List generation presets available for Character Studio. Returns base, identityEdit, and i2v presets as well as enhanceImage and enhanceVideo presets (each with supportedScales). Use the returned ids as presetId/imagePresetId/videoPresetId/basePresetId arguments in other character tools.', inputSchema: {} },
125
- wrap(() => client.listCharacterPresets()));
126
-
127
- server.registerTool('remove_character',
128
- { title: 'Remove character', description: 'Permanently delete a character and its associated data.', inputSchema: { characterId: z.string() } },
129
- wrap(({ characterId }) => client.removeCharacter(characterId)));
130
-
131
- server.registerTool('create_character',
132
- {
133
- title: 'Create character',
134
- description: 'Create a new character. Three paths: (1) describe -- pass gender and identity features from list_character_features with mode "generate"; the server builds a base portrait automatically and this tool waits until ready. (2) upload as base -- pass baseFilePath pointing to a local image with mode "upload"; the image becomes the base portrait immediately. (3) vision recreate -- describe path but fill identity by having the agent analyse a reference photo first; the agent extracts features and passes them as identity.',
135
- inputSchema: {
136
- name: z.string().describe('Display name for the character'),
137
- gender: z.enum(['woman', 'man', 'nonbinary']),
138
- mode: z.enum(['generate', 'upload']).describe('generate: build base portrait from description; upload: use a provided image as base'),
139
- identity: z.record(z.string()).optional().describe('An object of featureId to optionId pairs chosen from list_character_features (e.g. { hairColor: "black", eyeColor: "green" }). Do not include gender here; pass gender separately.'),
140
- baseFilePath: z.string().optional().describe('Absolute local image path; required for mode "upload"'),
141
- timeoutSeconds: z.number().optional().describe('Max seconds to wait for base portrait generation (default 300, mode generate only)'),
142
- pollMs: z.number().optional().describe('Poll interval in milliseconds (default 2000, mode generate only)'),
143
- },
144
- },
145
- async ({ name, gender, mode, identity, baseFilePath, timeoutSeconds, pollMs }) => {
146
- try {
147
- if (mode === 'upload') {
148
- const up = await client.uploadFile(baseFilePath);
149
- const char = await client.createCharacter({ name, gender, mode: 'upload', identity, baseHandle: up.handle });
150
- return ok({ ...char, ready: true });
151
- }
152
- // mode === 'generate': create then poll until base_image_url is set
153
- const c = await client.createCharacter({ name, gender, mode: 'generate', identity });
154
- const { characterId } = c;
155
- const deadline = Date.now() + (timeoutSeconds ?? 300) * 1000;
156
- while (true) {
157
- const { character } = await client.getCharacter(characterId);
158
- if (character?.base_image_url) return ok(character);
159
- if (Date.now() >= deadline) return ok({ ...character, base_pending: true });
160
- await new Promise((r) => setTimeout(r, pollMs ?? 2000));
161
- }
162
- } catch (e) {
163
- return fail(e);
164
- }
165
- });
166
-
167
- server.registerTool('generate_character_image',
168
- {
169
- title: 'Generate character image',
170
- description: 'Generate one or more images of a character in a scene. Returns jobIds; use wait_for_result then get_result_image for each. Pass presetId to override the default image preset (ids from list_character_presets).',
171
- inputSchema: {
172
- characterId: z.string(),
173
- prompt: z.string(),
174
- aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
175
- count: z.number().optional(),
176
- presetId: z.string().optional().describe('Override image preset (id from list_character_presets)'),
177
- },
178
- },
179
- wrap(({ characterId, prompt, aspect, count, presetId }) => {
180
- const args = { prompt, aspect, count };
181
- if (presetId !== undefined) args.presetId = presetId;
182
- return client.generateCharacterImage(characterId, args);
183
- }));
184
-
185
- server.registerTool('generate_character_video',
186
- {
187
- title: 'Generate character video',
188
- description: 'Generate a short video of a character in a scene. Internally generates a frame then animates it (I2V). Returns frameJobId and videoJobId; use wait_for_result then get_result_image on videoJobId. Use framePresetId to override the image-frame preset and videoPresetId to override the video/animate preset (ids from list_character_presets).',
189
- inputSchema: {
190
- characterId: z.string(),
191
- prompt: z.string(),
192
- motion: z.string().optional().describe('Optional motion description or style hint'),
193
- aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
194
- timeoutSeconds: z.number().optional().describe('Max seconds to wait for the frame job (default 300)'),
195
- pollMs: z.number().optional().describe('Poll interval in milliseconds (default 2000)'),
196
- framePresetId: z.string().optional().describe('Override image-frame preset (id from list_character_presets)'),
197
- videoPresetId: z.string().optional().describe('Override video/animate preset (id from list_character_presets)'),
198
- },
199
- },
200
- async ({ characterId, prompt, motion, aspect, timeoutSeconds = 300, pollMs = 2000, framePresetId, videoPresetId }) => {
201
- try {
202
- // 1. Generate a frame (still image for I2V)
203
- const { jobIds: [frameJobId] } = await client.generateCharacterFrame(characterId, { prompt, aspect, presetId: framePresetId });
204
- // 2. Wait for frame to complete
205
- const deadline = Date.now() + timeoutSeconds * 1000;
206
- while (true) {
207
- const { job: frameJob } = await client.getJob(frameJobId);
208
- if (frameJob?.status === 'completed') break;
209
- if (frameJob?.status === 'failed') return fail({ message: `Frame job failed: ${frameJobId}`, errorCode: 'frame_failed' });
210
- if (Date.now() >= deadline) return fail({ message: `Frame job timed out: ${frameJobId}`, errorCode: 'frame_timeout' });
211
- await new Promise((r) => setTimeout(r, pollMs));
212
- }
213
- // 3. Animate from the completed frame
214
- const { jobIds: [videoJobId] } = await client.animateCharacter(characterId, { frameJobId, motion, aspect, presetId: videoPresetId });
215
- return ok({ frameJobId, videoJobId });
216
- } catch (e) { return fail(e); }
217
- });
218
-
219
- // ---- New Character Studio tools (Task 4) ----
220
-
221
- server.registerTool('generate_character_frame',
222
- {
223
- title: 'Generate character frame',
224
- description: 'Generate a start-frame image for a character that you can review or enhance before animating. Returns jobIds; use wait_for_result then get_result_image. Pass presetId to override the frame-image preset (ids from list_character_presets).',
225
- inputSchema: {
226
- characterId: z.string(),
227
- prompt: z.string(),
228
- aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
229
- presetId: z.string().optional().describe('Override image preset (id from list_character_presets)'),
230
- },
231
- },
232
- wrap(({ characterId, prompt, aspect, presetId }) => {
233
- const args = { prompt, aspect };
234
- if (presetId !== undefined) args.presetId = presetId;
235
- return client.generateCharacterFrame(characterId, args);
236
- }));
237
-
238
- server.registerTool('animate_character',
239
- {
240
- title: 'Animate character',
241
- description: 'Animate a character from a completed image/frame job or a local file path (I2V). jobId may be any completed character image, frame, or upscale job. Returns jobIds; use wait_for_result then get_result_image. Pass videoPresetId to override the video preset (ids from list_character_presets).',
242
- inputSchema: {
243
- characterId: z.string(),
244
- jobId: z.string().optional().describe('Any completed character image/frame/upscale jobId to use as the source frame'),
245
- filePath: z.string().optional().describe('Absolute local image path to use as the source frame'),
246
- motion: z.string().optional().describe('Optional motion description or style hint'),
247
- aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
248
- videoPresetId: z.string().optional().describe('Override video/animate preset (id from list_character_presets)'),
249
- },
250
- },
251
- wrap(({ characterId, jobId, filePath, motion, aspect, videoPresetId }) =>
252
- client.animateCharacter(characterId, { frameJobId: jobId, framePath: filePath, motion, aspect, presetId: videoPresetId })));
253
-
254
- server.registerTool('set_character_preset',
255
- {
256
- title: 'Set character preset',
257
- description: 'Set sticky per-character default presets for image and/or video generation. Ids come from list_character_presets. Pass null to clear a preset back to the system default.',
258
- inputSchema: {
259
- characterId: z.string(),
260
- imagePresetId: z.string().nullable().optional().describe('Default image preset id (null to clear)'),
261
- videoPresetId: z.string().nullable().optional().describe('Default video preset id (null to clear)'),
262
- },
263
- },
264
- wrap(({ characterId, imagePresetId, videoPresetId }) => client.setCharacterPreset(characterId, { imagePresetId, videoPresetId })));
265
-
266
- server.registerTool('enhance_character_media',
267
- {
268
- title: 'Enhance character media',
269
- description: 'Upscale a generated image/video, a local file, or the character base portrait (target: "base"). Returns a jobId; use wait_for_result then get_result_image. scale sets the upscale factor; keepResolution upscales then resizes back to source size for a sharper same-size result; applyAsBase replaces the character base portrait with the upscaled result. Enhance presets come from list_character_presets (enhanceImage/enhanceVideo, each with supportedScales).',
270
- inputSchema: {
271
- characterId: z.string(),
272
- jobId: z.string().optional().describe('Completed job whose result to enhance'),
273
- filePath: z.string().optional().describe('Absolute local image/video path to enhance'),
274
- target: z.enum(['base']).optional().describe('Pass "base" to enhance the character base portrait'),
275
- mediaType: z.enum(['image', 'video']).optional(),
276
- presetId: z.string().optional().describe('Enhance preset id from list_character_presets'),
277
- scale: z.number().optional().describe('Upscale factor (e.g. 2 for 2x)'),
278
- keepResolution: z.boolean().optional().describe('Upscale then resize back to source dimensions for a sharper same-size result'),
279
- applyAsBase: z.boolean().optional().describe('Replace the character base portrait with the upscaled result'),
280
- },
281
- },
282
- wrap(({ characterId, jobId, filePath, target, mediaType, presetId, scale, keepResolution, applyAsBase }) =>
283
- client.enhanceCharacterMedia(characterId, { jobId, filePath, target, mediaType, presetId, scale, keepResolution, applyAsBase })));
284
-
285
- server.registerTool('regenerate_character_base',
286
- {
287
- title: 'Regenerate character base',
288
- description: 'Switch the base model and regenerate the base portrait IN PLACE (no new character created). Replaces the current base when completed. Uses basePresetId from list_character_presets. Optionally update identity features at the same time. Polls until completed, then auto-accepts the new base.',
289
- inputSchema: {
290
- characterId: z.string(),
291
- basePresetId: z.string().optional().describe('Base-generation preset id from list_character_presets'),
292
- identity: z.record(z.string()).optional().describe('Updated identity feature map (featureId -> optionId) to apply alongside the new base'),
293
- timeoutSeconds: z.number().optional().describe('Max seconds to wait for the base job (default 300)'),
294
- pollMs: z.number().optional().describe('Poll interval in milliseconds (default 2000)'),
295
- },
296
- },
297
- async ({ characterId, basePresetId, identity, timeoutSeconds = 300, pollMs = 2000 }) => {
298
- try {
299
- const { baseJobId } = await client.regenerateCharacterBase(characterId, { basePresetId, identity });
300
- const deadline = Date.now() + timeoutSeconds * 1000;
301
- while (true) {
302
- const { job } = await client.getJob(baseJobId);
303
- if (job?.status === 'completed') {
304
- await client.acceptBase(characterId, { altImageId: baseJobId });
305
- return ok(await client.getCharacter(characterId));
306
- }
307
- if (job?.status === 'failed') return fail({ message: `Base regeneration job failed: ${baseJobId}`, errorCode: 'base_failed' });
308
- if (Date.now() >= deadline) return ok({ baseJobId, note: 'timeout_still_running' });
309
- await new Promise((r) => setTimeout(r, pollMs));
310
- }
311
- } catch (e) { return fail(e); }
312
- });
313
-
314
- // --- Recipe authoring: compose multi-step recipes out of packs/presets ---
315
-
316
- const recipeStep = z.object({
317
- preset: z.string().describe('The preset this step runs, as "packId:presetId" (from list_recipe_blocks).'),
318
- entry: z.string().optional().describe('Entry point: create-image | create-video | edit-image | upscale. Inferred from the preset when omitted.'),
319
- promptInput: z.object({ key: z.string().optional(), label: z.string().optional() }).optional()
320
- .describe('Name this step\'s prompt input, or set key to an existing input to SHARE a prompt. Default: a dedicated prompt input per step.'),
321
- negativePrompt: z.string().optional(),
322
- params: z.record(z.any()).optional().describe('Param bindings, literal or { from: "input:<key>" } (e.g. aspectRatio, seed, duration).'),
323
- }).passthrough();
324
-
325
- const recipeInput = z.object({
326
- key: z.string().describe('Unique slug referenced as input:<key>.'),
327
- kind: z.string().describe('subject | text | image | video | reference | audio | select | color | number | toggle'),
328
- label: z.string().optional(),
329
- required: z.boolean().optional(),
330
- }).passthrough();
331
-
332
- const recipeSpec = z.object({
333
- name: z.string().describe('Recipe name shown to end users.'),
334
- description: z.string().optional(),
335
- category: z.string().optional().describe('e.g. product | ugc'),
336
- mature: z.boolean().optional().describe('Creator-declared: this recipe produces adult content.'),
337
- // Accepted, not for the benefit of third parties (breaking them is fine), but
338
- // because an agent composing a recipe mid-transition would otherwise DROP the
339
- // classification, which publishes adult content as unclassified (TASK-472).
340
- nsfw: z.boolean().optional().describe('Deprecated alias for `mature`.'),
341
- version: z.string().optional().describe('Creator-facing semver, default 1.0.0.'),
342
- inputs: z.array(recipeInput).optional().describe('Extra user-facing inputs (subjects, choices, media holes). A prompt input is auto-created per step, so you rarely list prompts here.'),
343
- steps: z.array(recipeStep).min(1).describe('The pipeline in order. Each step pins a preset; media output flows into the next step automatically, and a subject input seeds a reference port.'),
344
- });
345
-
346
- server.registerTool('list_recipe_blocks',
347
- { title: 'List recipe blocks', description: 'The building blocks for authoring a recipe: entry points (create-image/video, edit-image, upscale) and every preset usable as a step, each with its port contract (inputs it needs, what it produces, which entry points it fits). Call this before compose_recipe to choose presets.', inputSchema: {} },
348
- wrap(() => client.listRecipeBlocks()));
349
-
350
- server.registerTool('compose_recipe',
351
- { title: 'Compose a recipe', description: 'Author a new recipe from a high-level spec and save it as a draft. You give an ordered list of steps (each pinned to a preset from list_recipe_blocks) plus any extra inputs; SimpliGen auto-wires the ports, media chain, and per-step prompt inputs and validates it. Returns { recipeId, valid, errors }. Anyone can compose; publishing later needs a connected account.', inputSchema: { spec: recipeSpec } },
352
- wrap(({ spec }) => client.composeRecipe({ spec })));
353
-
354
- server.registerTool('validate_recipe',
355
- { title: 'Validate a recipe', description: 'Dry-run without saving: pass a `spec` to compose+validate it, or a `config` to validate an existing recipe config. Returns precise errors (unbound ports, missing presets, type mismatches, cycles) to fix before compose_recipe.', inputSchema: { spec: recipeSpec.optional(), config: z.record(z.any()).optional() } },
356
- wrap((args) => client.validateRecipe(args)));
357
-
358
- server.registerTool('list_recipes',
359
- { title: 'List recipes', description: 'List every recipe on this device: the ones installed from the Store into UGC Studio / Product Studio (category ugc | product) and drafts authored here. Each entry carries its `inputs` (key, kind, label, required, options) so you know what run_recipe needs: a `subject` input takes a character id from list_characters (or a product id), image/video/audio inputs take upload_file handles, text/select/number inputs take plain values.', inputSchema: {} },
360
- wrap(() => client.listRecipes()));
361
-
362
- // --- Running an installed recipe (what drives the UGC + Product Studios) ---
363
-
364
- server.registerTool('run_recipe',
365
- {
366
- title: 'Run a recipe',
367
- description: 'Run an installed recipe (from list_recipes) with its inputs filled: the same multi-step pipeline UGC Studio / Product Studio runs when a person clicks Generate, so results land in the Gallery grouped the same way. `inputs` is keyed by input key; a subject input takes a character/product id, media inputs take upload_file handles. engine: local (default when every step is installed) | cloud | auto. Returns a runId; poll with wait_for_recipe_run or get_recipe_run. Cloud runs are priced up front and count against this agent\'s spend cap.',
368
- inputSchema: {
369
- id: z.string().describe('Recipe id from list_recipes'),
370
- inputs: z.record(z.any()).describe('Input values keyed by the recipe\'s input keys (see list_recipes -> inputs).'),
371
- engine: z.enum(['local', 'cloud', 'auto']).optional().describe('Where the steps render. auto = local if every step is installed, else cloud when connected.'),
372
- },
373
- },
374
- wrap(({ id, inputs, engine }) => client.runRecipe(id, { inputs: inputs || {}, ...(engine ? { engine } : {}) })));
375
-
376
- server.registerTool('get_recipe_run',
377
- { title: 'Get a recipe run', description: 'Status of a recipe run: overall status (running | completed | failed | cancelled), each step with its status and resultPath, and resultPath = the final output once done.', inputSchema: { runId: z.string().describe('Run id from run_recipe') } },
378
- wrap(({ runId }) => client.getRecipeRun(runId)));
379
-
380
- server.registerTool('wait_for_recipe_run',
381
- { title: 'Wait for a recipe run', description: 'Block until a recipe run completes, fails, is cancelled, or the timeout passes. Returns the final run (resultPath when completed). Multi-step video recipes can take several minutes; call again if it returns timeout_still_running.', inputSchema: { runId: z.string(), timeoutSeconds: z.number().optional(), pollMs: z.number().optional() } },
382
- wrap(async ({ runId, timeoutSeconds = 600, pollMs = 3000 }) => {
383
- const deadline = Date.now() + timeoutSeconds * 1000;
384
- // eslint-disable-next-line no-constant-condition
385
- while (true) {
386
- const { run } = await client.getRecipeRun(runId);
387
- if (run && ['completed', 'failed', 'cancelled'].includes(run.status)) return { run };
388
- if (Date.now() >= deadline) return { run, note: 'timeout_still_running' };
389
- await new Promise((r) => setTimeout(r, pollMs));
390
- }
391
- }));
392
-
393
- server.registerTool('list_recipe_runs',
394
- { title: 'List recipe runs', description: 'Recent runs of a recipe (newest first) with their status and results.', inputSchema: { id: z.string().describe('Recipe id') } },
395
- wrap(({ id }) => client.listRecipeRuns(id)));
396
-
397
- // --- Products + subjects: what a recipe's `subject` inputs take ---
398
-
399
- server.registerTool('list_subjects',
400
- { title: 'List subjects', description: 'Every subject a recipe\'s `subject` input can take: characters and products (id, type, name). Use a character id from list_characters or a product id from list_products / create_product.', inputSchema: {} },
401
- wrap(() => client.listSubjects()));
402
-
403
- server.registerTool('list_products',
404
- { title: 'List products', description: 'The products saved on this device (a product = a named thing with a base photo, used by Product Studio and UGC recipes).', inputSchema: {} },
405
- wrap(() => client.listProducts()));
406
-
407
- server.registerTool('create_product',
408
- {
409
- title: 'Create a product',
410
- description: 'Save a product from a photo so recipes can use it: upload the photo with upload_file first, then pass its handle. The description and category are woven into recipe prompts, so describe what it is and how it looks. Returns the productId to pass as a `subject` input in run_recipe.',
411
- inputSchema: {
412
- name: z.string().describe('Product name shown in the app'),
413
- image: z.string().describe('upload_file handle of the product photo (clean, well-lit, product centred)'),
414
- description: z.string().optional().describe('What it is and looks like, e.g. "a matte black ceramic coffee mug with a wooden lid"'),
415
- category: z.string().optional().describe('e.g. skincare | beverage | electronics'),
416
- },
417
- },
418
- wrap(({ name, image, description, category }) => client.createProduct({ name, image, description, category })));
419
-
420
- server.registerTool('remove_product',
421
- { title: 'Remove a product', description: 'Delete a product and its photo from this device.', inputSchema: { id: z.string() } },
422
- wrap(({ id }) => client.removeProduct(id)));
423
-
424
- server.registerTool('cancel_recipe_run',
425
- { title: 'Cancel a recipe run', description: 'Stop a running recipe: no further steps start; a local in-flight step is interrupted (a cloud step already running finishes server-side and is saved).', inputSchema: { runId: z.string() } },
426
- wrap(({ runId }) => client.cancelRecipeRun(runId)));
427
-
428
- server.registerTool('get_recipe',
429
- { title: 'Get a recipe', description: 'Get a saved recipe and its full config by id.', inputSchema: { id: z.string().describe('Recipe id from compose_recipe or list_recipes') } },
430
- wrap(({ id }) => client.getRecipe(id)));
431
-
432
- server.registerTool('publish_recipe',
433
- { title: 'Publish a recipe', description: 'Submit a saved recipe to the SimpliGen Creator Marketplace (queued for human review). Requires a connected account: if none, returns not_connected and the user connects once in Settings, then this succeeds on the same recipe. priceTokens sets an optional one-time price (0 or omitted = free). Every preset the recipe uses must be a distributable first-party pack.', inputSchema: { id: z.string().describe('Recipe id to publish'), changelog: z.string().optional(), priceTokens: z.number().optional().describe('One-time price in tokens (0/omitted = free).') } },
434
- wrap(({ id, changelog, priceTokens }) => client.publishRecipe(id, { changelog, priceTokens })));
435
- }
1
+ // packages/mcp-server/src/tools.js
2
+ // Registers SimpliGen MCP tools on an McpServer-shaped object. Each tool calls a
3
+ // ControlApiClient method and returns the JSON result as text content; client
4
+ // errors become an isError tool result (so the agent sees the errorCode/message).
5
+ import { z } from 'zod';
6
+ import { buildResultImageContent } from './resultImage.js';
7
+
8
+ export const TOOL_NAMES = [
9
+ 'list_capabilities', 'get_status', 'list_projects', 'create_project',
10
+ 'upload_file', 'generate', 'list_loras', 'get_job', 'wait_for_result', 'list_jobs',
11
+ 'cancel_job', 'prepare_preset', 'get_result_image',
12
+ 'list_characters', 'get_character', 'list_character_features', 'list_character_presets',
13
+ 'remove_character', 'create_character', 'generate_character_image', 'generate_character_video',
14
+ 'generate_character_frame', 'animate_character', 'set_character_preset',
15
+ 'enhance_character_media', 'regenerate_character_base',
16
+ 'list_recipe_blocks', 'compose_recipe', 'validate_recipe', 'list_recipes', 'get_recipe', 'publish_recipe',
17
+ 'run_recipe', 'get_recipe_run', 'wait_for_recipe_run', 'list_recipe_runs', 'cancel_recipe_run',
18
+ 'list_subjects', 'list_products', 'create_product', 'remove_product',
19
+ ];
20
+
21
+ const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
22
+ const fail = (err) => ({ isError: true, content: [{ type: 'text', text: `${err.errorCode ? err.errorCode + ': ' : ''}${err.message || String(err)}` }] });
23
+ const wrap = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } };
24
+
25
+ export function registerTools(server, client) {
26
+ server.registerTool('list_capabilities',
27
+ { title: 'List capabilities', description: 'List every SimpliGen preset the agent can generate with (image/video), its inputs/options, whether it is installed locally (localReady) and cloud-eligible + credit cost.', inputSchema: {} },
28
+ wrap(() => client.getCapabilities()));
29
+
30
+ server.registerTool('get_status',
31
+ { title: 'Get status', description: 'Engine running state, whether cloud is connected, credit balance, and the active project.', inputSchema: {} },
32
+ wrap(() => client.getStatus()));
33
+
34
+ server.registerTool('list_projects',
35
+ { title: 'List projects', description: 'List projects (output containers).', inputSchema: {} },
36
+ wrap(() => client.listProjects()));
37
+
38
+ server.registerTool('create_project',
39
+ { title: 'Create project', description: 'Create a new project (output container).', inputSchema: { name: z.string().describe('Project name') } },
40
+ wrap(({ name }) => client.createProject(name)));
41
+
42
+ server.registerTool('upload_file',
43
+ { title: 'Upload a file', description: 'Upload a local image/video file and get an opaque handle to pass as image/referenceImages/video in generate.', inputSchema: { path: z.string().describe('Absolute local file path') } },
44
+ wrap(({ path }) => client.uploadFile(path)));
45
+
46
+ server.registerTool('generate',
47
+ {
48
+ title: 'Generate', description: 'Start an image or video generation. Inputs (image/referenceImages/video) are handles from upload_file. backend: local | cloud | auto. Returns a jobId; poll with wait_for_result or get_job.',
49
+ inputSchema: {
50
+ presetId: z.string(), mediaType: z.enum(['image', 'video']), prompt: z.string(),
51
+ packId: z.string().optional(), backend: z.enum(['local', 'cloud', 'auto']).optional(),
52
+ negativePrompt: z.string().optional(), project: z.string().optional(),
53
+ image: z.string().optional().describe('upload_file handle'),
54
+ referenceImages: z.array(z.string()).optional().describe('upload_file handles'),
55
+ video: z.string().optional().describe('upload_file handle (driving video)'),
56
+ loras: z.array(z.object({ name: z.string().describe('LoRA display name from list_loras'), weight: z.number().optional().describe('Strength; defaults to the LoRA default') })).optional().describe('LoRAs to apply. Local backend only, and only for presets where inputs.supportsLoras is true. Use the LoRA trigger words in the prompt so it activates.'),
57
+ // .passthrough(), not the zod default: a plain z.object STRIPS every key it
58
+ // does not name, silently. The App's /generate accepts any Advanced
59
+ // Control the preset declares (unlock, shift, high_steps, ...) and
60
+ // list_capabilities advertises them under options.controls, so an agent
61
+ // was told a control exists, sent it, and got the preset default back
62
+ // with no error: unlock: 1 arrived at the graph as 0.5 (Melvyl, 1.63.0).
63
+ // The App applies only what the preset declares and ignores the rest,
64
+ // so forwarding everything is safe. Known fields stay named so the
65
+ // agent still sees them in the tool's schema.
66
+ options: z.object({
67
+ resolution: z.string().optional().describe('Resolution tier label; see list_capabilities -> options.resolution.'),
68
+ aspectRatio: z.string().optional().describe('Aspect ratio, e.g. "1:1", "16:9", "9:16" (portrait/vertical), "4:3", "3:4". See list_capabilities -> options.aspectRatio for the values each preset accepts. Defaults to 1:1 for images (16:9 for video) if omitted.'),
69
+ durationSeconds: z.number().optional(),
70
+ steps: z.number().optional(),
71
+ cfg: z.number().optional(),
72
+ seed: z.number().optional(),
73
+ negativePrompt: z.string().optional(),
74
+ fasterAttention: z.boolean().optional().describe('Accelerator toggle; absent = preset default.'),
75
+ solAttn: z.boolean().optional().describe('Accelerator toggle; absent = preset default.'),
76
+ spectrum: z.boolean().optional().describe('Accelerator toggle; absent = preset default.'),
77
+ }).passthrough().optional().describe('Run options. Besides the named fields, pass ANY Advanced Control the preset declares under list_capabilities -> options.controls (for example unlock, shift, high_steps) as a top-level key of this object, using the value range that map gives. A key the preset does not declare is ignored.'),
78
+ },
79
+ },
80
+ wrap((args) => client.generate(args)));
81
+
82
+ server.registerTool('list_loras',
83
+ { title: 'List LoRAs', description: 'List the installed user LoRAs (name, base model, trigger words) so you can pass them in generate -> loras.', inputSchema: {} },
84
+ wrap(() => client.listLoras()));
85
+
86
+ server.registerTool('get_job',
87
+ { title: 'Get job', description: 'Status + result of a generation job. resultPath is the local file when completed.', inputSchema: { jobId: z.string() } },
88
+ wrap(({ jobId }) => client.getJob(jobId)));
89
+
90
+ server.registerTool('list_jobs',
91
+ { title: 'List jobs', description: 'Recent generation jobs.', inputSchema: {} },
92
+ wrap(() => client.listJobs()));
93
+
94
+ server.registerTool('cancel_job',
95
+ { title: 'Cancel job', description: 'Cancel a queued/running job.', inputSchema: { jobId: z.string() } },
96
+ wrap(({ jobId }) => client.cancelJob(jobId)));
97
+
98
+ server.registerTool('prepare_preset',
99
+ { title: 'Prepare preset', description: 'Start downloading a local preset\'s models (so it becomes localReady). Poll list_capabilities for localReady.', inputSchema: { presetId: z.string(), mediaType: z.enum(['image', 'video']), packId: z.string().optional() } },
100
+ wrap((args) => client.preparePreset(args)));
101
+
102
+ // Convenience: poll get_job until terminal or timeout (no extra endpoint).
103
+ server.registerTool('wait_for_result',
104
+ { title: 'Wait for result', description: 'Block until a job completes or fails (or the timeout). Returns the final job (resultPath when completed).', inputSchema: { jobId: z.string(), timeoutSeconds: z.number().optional(), pollMs: z.number().optional() } },
105
+ wrap(async ({ jobId, timeoutSeconds = 300, pollMs = 2000 }) => {
106
+ const deadline = Date.now() + timeoutSeconds * 1000;
107
+ // eslint-disable-next-line no-constant-condition
108
+ while (true) {
109
+ const { job } = await client.getJob(jobId);
110
+ if (!job || job.status === 'completed' || job.status === 'failed') return job;
111
+ if (Date.now() >= deadline) return { ...job, note: 'timeout_still_running' };
112
+ await new Promise((r) => setTimeout(r, pollMs));
113
+ }
114
+ }));
115
+
116
+ // Return a completed job's result image inline (an MCP image block) so the
117
+ // agent can actually display it. Images only; video/unreadable -> path note.
118
+ server.registerTool('get_result_image',
119
+ { title: 'Get result image', description: 'Return a completed job\'s result image inline as a viewable image so it can be displayed in chat. Large images are re-encoded to a full-resolution WebP preview that fits the client size cap; the uncompressed original file path is always included in the caption. Video results return the file path instead. Call after the job is completed.', inputSchema: { jobId: z.string(), maxBytes: z.number().optional().describe('Inline byte budget before re-encoding/resizing (default ~700KB raw)') } },
120
+ async ({ jobId, maxBytes }) => {
121
+ try {
122
+ const { job } = await client.getJob(jobId);
123
+ return await buildResultImageContent(job, { budgetBytes: maxBytes });
124
+ } catch (e) {
125
+ return fail(e);
126
+ }
127
+ });
128
+
129
+ // ---- Character Studio tools ----
130
+
131
+ server.registerTool('list_characters',
132
+ { title: 'List characters', description: 'List all saved characters in Character Studio.', inputSchema: {} },
133
+ wrap(() => client.listCharacters()));
134
+
135
+ server.registerTool('get_character',
136
+ { title: 'Get character', description: 'Get a single character by ID, including its current base portrait and identity features.', inputSchema: { characterId: z.string() } },
137
+ wrap(({ characterId }) => client.getCharacter(characterId)));
138
+
139
+ server.registerTool('list_character_features',
140
+ { title: 'List character features', description: 'List available feature categories and their options (e.g. hairColor, eyeColor) for use with create_character identity.', inputSchema: {} },
141
+ wrap(() => client.listCharacterFeatures()));
142
+
143
+ server.registerTool('list_character_presets',
144
+ { title: 'List character presets', description: 'List generation presets available for Character Studio. Returns base, identityEdit, and i2v presets as well as enhanceImage and enhanceVideo presets (each with supportedScales). Use the returned ids as presetId/imagePresetId/videoPresetId/basePresetId arguments in other character tools.', inputSchema: {} },
145
+ wrap(() => client.listCharacterPresets()));
146
+
147
+ server.registerTool('remove_character',
148
+ { title: 'Remove character', description: 'Permanently delete a character and its associated data.', inputSchema: { characterId: z.string() } },
149
+ wrap(({ characterId }) => client.removeCharacter(characterId)));
150
+
151
+ server.registerTool('create_character',
152
+ {
153
+ title: 'Create character',
154
+ description: 'Create a new character. Three paths: (1) describe -- pass gender and identity features from list_character_features with mode "generate"; the server builds a base portrait automatically and this tool waits until ready. (2) upload as base -- pass baseFilePath pointing to a local image with mode "upload"; the image becomes the base portrait immediately. (3) vision recreate -- describe path but fill identity by having the agent analyse a reference photo first; the agent extracts features and passes them as identity.',
155
+ inputSchema: {
156
+ name: z.string().describe('Display name for the character'),
157
+ gender: z.enum(['woman', 'man', 'nonbinary']),
158
+ mode: z.enum(['generate', 'upload']).describe('generate: build base portrait from description; upload: use a provided image as base'),
159
+ identity: z.record(z.string()).optional().describe('An object of featureId to optionId pairs chosen from list_character_features (e.g. { hairColor: "black", eyeColor: "green" }). Do not include gender here; pass gender separately.'),
160
+ baseFilePath: z.string().optional().describe('Absolute local image path; required for mode "upload"'),
161
+ timeoutSeconds: z.number().optional().describe('Max seconds to wait for base portrait generation (default 300, mode generate only)'),
162
+ pollMs: z.number().optional().describe('Poll interval in milliseconds (default 2000, mode generate only)'),
163
+ },
164
+ },
165
+ async ({ name, gender, mode, identity, baseFilePath, timeoutSeconds, pollMs }) => {
166
+ try {
167
+ if (mode === 'upload') {
168
+ const up = await client.uploadFile(baseFilePath);
169
+ const char = await client.createCharacter({ name, gender, mode: 'upload', identity, baseHandle: up.handle });
170
+ return ok({ ...char, ready: true });
171
+ }
172
+ // mode === 'generate': create then poll until base_image_url is set
173
+ const c = await client.createCharacter({ name, gender, mode: 'generate', identity });
174
+ const { characterId } = c;
175
+ const deadline = Date.now() + (timeoutSeconds ?? 300) * 1000;
176
+ while (true) {
177
+ const { character } = await client.getCharacter(characterId);
178
+ if (character?.base_image_url) return ok(character);
179
+ if (Date.now() >= deadline) return ok({ ...character, base_pending: true });
180
+ await new Promise((r) => setTimeout(r, pollMs ?? 2000));
181
+ }
182
+ } catch (e) {
183
+ return fail(e);
184
+ }
185
+ });
186
+
187
+ server.registerTool('generate_character_image',
188
+ {
189
+ title: 'Generate character image',
190
+ description: 'Generate one or more images of a character in a scene. Returns jobIds; use wait_for_result then get_result_image for each. Pass presetId to override the default image preset (ids from list_character_presets).',
191
+ inputSchema: {
192
+ characterId: z.string(),
193
+ prompt: z.string(),
194
+ aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
195
+ count: z.number().optional(),
196
+ presetId: z.string().optional().describe('Override image preset (id from list_character_presets)'),
197
+ },
198
+ },
199
+ wrap(({ characterId, prompt, aspect, count, presetId }) => {
200
+ const args = { prompt, aspect, count };
201
+ if (presetId !== undefined) args.presetId = presetId;
202
+ return client.generateCharacterImage(characterId, args);
203
+ }));
204
+
205
+ server.registerTool('generate_character_video',
206
+ {
207
+ title: 'Generate character video',
208
+ description: 'Generate a short video of a character in a scene. Internally generates a frame then animates it (I2V). Returns frameJobId and videoJobId; use wait_for_result then get_result_image on videoJobId. Use framePresetId to override the image-frame preset and videoPresetId to override the video/animate preset (ids from list_character_presets).',
209
+ inputSchema: {
210
+ characterId: z.string(),
211
+ prompt: z.string(),
212
+ motion: z.string().optional().describe('Optional motion description or style hint'),
213
+ aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
214
+ timeoutSeconds: z.number().optional().describe('Max seconds to wait for the frame job (default 300)'),
215
+ pollMs: z.number().optional().describe('Poll interval in milliseconds (default 2000)'),
216
+ framePresetId: z.string().optional().describe('Override image-frame preset (id from list_character_presets)'),
217
+ videoPresetId: z.string().optional().describe('Override video/animate preset (id from list_character_presets)'),
218
+ },
219
+ },
220
+ async ({ characterId, prompt, motion, aspect, timeoutSeconds = 300, pollMs = 2000, framePresetId, videoPresetId }) => {
221
+ try {
222
+ // 1. Generate a frame (still image for I2V)
223
+ const { jobIds: [frameJobId] } = await client.generateCharacterFrame(characterId, { prompt, aspect, presetId: framePresetId });
224
+ // 2. Wait for frame to complete
225
+ const deadline = Date.now() + timeoutSeconds * 1000;
226
+ while (true) {
227
+ const { job: frameJob } = await client.getJob(frameJobId);
228
+ if (frameJob?.status === 'completed') break;
229
+ if (frameJob?.status === 'failed') return fail({ message: `Frame job failed: ${frameJobId}`, errorCode: 'frame_failed' });
230
+ if (Date.now() >= deadline) return fail({ message: `Frame job timed out: ${frameJobId}`, errorCode: 'frame_timeout' });
231
+ await new Promise((r) => setTimeout(r, pollMs));
232
+ }
233
+ // 3. Animate from the completed frame
234
+ const { jobIds: [videoJobId] } = await client.animateCharacter(characterId, { frameJobId, motion, aspect, presetId: videoPresetId });
235
+ return ok({ frameJobId, videoJobId });
236
+ } catch (e) { return fail(e); }
237
+ });
238
+
239
+ // ---- New Character Studio tools (Task 4) ----
240
+
241
+ server.registerTool('generate_character_frame',
242
+ {
243
+ title: 'Generate character frame',
244
+ description: 'Generate a start-frame image for a character that you can review or enhance before animating. Returns jobIds; use wait_for_result then get_result_image. Pass presetId to override the frame-image preset (ids from list_character_presets).',
245
+ inputSchema: {
246
+ characterId: z.string(),
247
+ prompt: z.string(),
248
+ aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
249
+ presetId: z.string().optional().describe('Override image preset (id from list_character_presets)'),
250
+ },
251
+ },
252
+ wrap(({ characterId, prompt, aspect, presetId }) => {
253
+ const args = { prompt, aspect };
254
+ if (presetId !== undefined) args.presetId = presetId;
255
+ return client.generateCharacterFrame(characterId, args);
256
+ }));
257
+
258
+ server.registerTool('animate_character',
259
+ {
260
+ title: 'Animate character',
261
+ description: 'Animate a character from a completed image/frame job or a local file path (I2V). jobId may be any completed character image, frame, or upscale job. Returns jobIds; use wait_for_result then get_result_image. Pass videoPresetId to override the video preset (ids from list_character_presets).',
262
+ inputSchema: {
263
+ characterId: z.string(),
264
+ jobId: z.string().optional().describe('Any completed character image/frame/upscale jobId to use as the source frame'),
265
+ filePath: z.string().optional().describe('Absolute local image path to use as the source frame'),
266
+ motion: z.string().optional().describe('Optional motion description or style hint'),
267
+ aspect: z.enum(['16:9', '9:16', '1:1']).optional(),
268
+ videoPresetId: z.string().optional().describe('Override video/animate preset (id from list_character_presets)'),
269
+ },
270
+ },
271
+ wrap(({ characterId, jobId, filePath, motion, aspect, videoPresetId }) =>
272
+ client.animateCharacter(characterId, { frameJobId: jobId, framePath: filePath, motion, aspect, presetId: videoPresetId })));
273
+
274
+ server.registerTool('set_character_preset',
275
+ {
276
+ title: 'Set character preset',
277
+ description: 'Set sticky per-character default presets for image and/or video generation. Ids come from list_character_presets. Pass null to clear a preset back to the system default.',
278
+ inputSchema: {
279
+ characterId: z.string(),
280
+ imagePresetId: z.string().nullable().optional().describe('Default image preset id (null to clear)'),
281
+ videoPresetId: z.string().nullable().optional().describe('Default video preset id (null to clear)'),
282
+ },
283
+ },
284
+ wrap(({ characterId, imagePresetId, videoPresetId }) => client.setCharacterPreset(characterId, { imagePresetId, videoPresetId })));
285
+
286
+ server.registerTool('enhance_character_media',
287
+ {
288
+ title: 'Enhance character media',
289
+ description: 'Upscale a generated image/video, a local file, or the character base portrait (target: "base"). Returns a jobId; use wait_for_result then get_result_image. scale sets the upscale factor; keepResolution upscales then resizes back to source size for a sharper same-size result; applyAsBase replaces the character base portrait with the upscaled result. Enhance presets come from list_character_presets (enhanceImage/enhanceVideo, each with supportedScales).',
290
+ inputSchema: {
291
+ characterId: z.string(),
292
+ jobId: z.string().optional().describe('Completed job whose result to enhance'),
293
+ filePath: z.string().optional().describe('Absolute local image/video path to enhance'),
294
+ target: z.enum(['base']).optional().describe('Pass "base" to enhance the character base portrait'),
295
+ mediaType: z.enum(['image', 'video']).optional(),
296
+ presetId: z.string().optional().describe('Enhance preset id from list_character_presets'),
297
+ scale: z.number().optional().describe('Upscale factor (e.g. 2 for 2x)'),
298
+ keepResolution: z.boolean().optional().describe('Upscale then resize back to source dimensions for a sharper same-size result'),
299
+ applyAsBase: z.boolean().optional().describe('Replace the character base portrait with the upscaled result'),
300
+ },
301
+ },
302
+ wrap(({ characterId, jobId, filePath, target, mediaType, presetId, scale, keepResolution, applyAsBase }) =>
303
+ client.enhanceCharacterMedia(characterId, { jobId, filePath, target, mediaType, presetId, scale, keepResolution, applyAsBase })));
304
+
305
+ server.registerTool('regenerate_character_base',
306
+ {
307
+ title: 'Regenerate character base',
308
+ description: 'Switch the base model and regenerate the base portrait IN PLACE (no new character created). Replaces the current base when completed. Uses basePresetId from list_character_presets. Optionally update identity features at the same time. Polls until completed, then auto-accepts the new base.',
309
+ inputSchema: {
310
+ characterId: z.string(),
311
+ basePresetId: z.string().optional().describe('Base-generation preset id from list_character_presets'),
312
+ identity: z.record(z.string()).optional().describe('Updated identity feature map (featureId -> optionId) to apply alongside the new base'),
313
+ timeoutSeconds: z.number().optional().describe('Max seconds to wait for the base job (default 300)'),
314
+ pollMs: z.number().optional().describe('Poll interval in milliseconds (default 2000)'),
315
+ },
316
+ },
317
+ async ({ characterId, basePresetId, identity, timeoutSeconds = 300, pollMs = 2000 }) => {
318
+ try {
319
+ const { baseJobId } = await client.regenerateCharacterBase(characterId, { basePresetId, identity });
320
+ const deadline = Date.now() + timeoutSeconds * 1000;
321
+ while (true) {
322
+ const { job } = await client.getJob(baseJobId);
323
+ if (job?.status === 'completed') {
324
+ await client.acceptBase(characterId, { altImageId: baseJobId });
325
+ return ok(await client.getCharacter(characterId));
326
+ }
327
+ if (job?.status === 'failed') return fail({ message: `Base regeneration job failed: ${baseJobId}`, errorCode: 'base_failed' });
328
+ if (Date.now() >= deadline) return ok({ baseJobId, note: 'timeout_still_running' });
329
+ await new Promise((r) => setTimeout(r, pollMs));
330
+ }
331
+ } catch (e) { return fail(e); }
332
+ });
333
+
334
+ // --- Recipe authoring: compose multi-step recipes out of packs/presets ---
335
+
336
+ const recipeStep = z.object({
337
+ preset: z.string().describe('The preset this step runs, as "packId:presetId" (from list_recipe_blocks).'),
338
+ entry: z.string().optional().describe('Entry point: create-image | create-video | edit-image | upscale. Inferred from the preset when omitted.'),
339
+ promptInput: z.object({ key: z.string().optional(), label: z.string().optional() }).optional()
340
+ .describe('Name this step\'s prompt input, or set key to an existing input to SHARE a prompt. Default: a dedicated prompt input per step.'),
341
+ negativePrompt: z.string().optional(),
342
+ params: z.record(z.any()).optional().describe('Param bindings, literal or { from: "input:<key>" } (e.g. aspectRatio, seed, duration).'),
343
+ }).passthrough();
344
+
345
+ const recipeInput = z.object({
346
+ key: z.string().describe('Unique slug referenced as input:<key>.'),
347
+ kind: z.string().describe('subject | text | image | video | reference | audio | select | color | number | toggle'),
348
+ label: z.string().optional(),
349
+ required: z.boolean().optional(),
350
+ }).passthrough();
351
+
352
+ const recipeSpec = z.object({
353
+ name: z.string().describe('Recipe name shown to end users.'),
354
+ description: z.string().optional(),
355
+ category: z.string().optional().describe('e.g. product | ugc'),
356
+ mature: z.boolean().optional().describe('Creator-declared: this recipe produces adult content.'),
357
+ // Accepted, not for the benefit of third parties (breaking them is fine), but
358
+ // because an agent composing a recipe mid-transition would otherwise DROP the
359
+ // classification, which publishes adult content as unclassified (TASK-472).
360
+ nsfw: z.boolean().optional().describe('Deprecated alias for `mature`.'),
361
+ version: z.string().optional().describe('Creator-facing semver, default 1.0.0.'),
362
+ inputs: z.array(recipeInput).optional().describe('Extra user-facing inputs (subjects, choices, media holes). A prompt input is auto-created per step, so you rarely list prompts here.'),
363
+ steps: z.array(recipeStep).min(1).describe('The pipeline in order. Each step pins a preset; media output flows into the next step automatically, and a subject input seeds a reference port.'),
364
+ });
365
+
366
+ server.registerTool('list_recipe_blocks',
367
+ { title: 'List recipe blocks', description: 'The building blocks for authoring a recipe: entry points (create-image/video, edit-image, upscale) and every preset usable as a step, each with its port contract (inputs it needs, what it produces, which entry points it fits). Call this before compose_recipe to choose presets.', inputSchema: {} },
368
+ wrap(() => client.listRecipeBlocks()));
369
+
370
+ server.registerTool('compose_recipe',
371
+ { title: 'Compose a recipe', description: 'Author a new recipe from a high-level spec and save it as a draft. You give an ordered list of steps (each pinned to a preset from list_recipe_blocks) plus any extra inputs; SimpliGen auto-wires the ports, media chain, and per-step prompt inputs and validates it. Returns { recipeId, valid, errors }. Anyone can compose; publishing later needs a connected account.', inputSchema: { spec: recipeSpec } },
372
+ wrap(({ spec }) => client.composeRecipe({ spec })));
373
+
374
+ server.registerTool('validate_recipe',
375
+ { title: 'Validate a recipe', description: 'Dry-run without saving: pass a `spec` to compose+validate it, or a `config` to validate an existing recipe config. Returns precise errors (unbound ports, missing presets, type mismatches, cycles) to fix before compose_recipe.', inputSchema: { spec: recipeSpec.optional(), config: z.record(z.any()).optional() } },
376
+ wrap((args) => client.validateRecipe(args)));
377
+
378
+ server.registerTool('list_recipes',
379
+ { title: 'List recipes', description: 'List every recipe on this device: the ones installed from the Store into UGC Studio / Product Studio (category ugc | product) and drafts authored here. Each entry carries its `inputs` (key, kind, label, required, options) so you know what run_recipe needs: a `subject` input takes a character id from list_characters (or a product id), image/video/audio inputs take upload_file handles, text/select/number inputs take plain values.', inputSchema: {} },
380
+ wrap(() => client.listRecipes()));
381
+
382
+ // --- Running an installed recipe (what drives the UGC + Product Studios) ---
383
+
384
+ server.registerTool('run_recipe',
385
+ {
386
+ title: 'Run a recipe',
387
+ description: 'Run an installed recipe (from list_recipes) with its inputs filled: the same multi-step pipeline UGC Studio / Product Studio runs when a person clicks Generate, so results land in the Gallery grouped the same way. `inputs` is keyed by input key; a subject input takes a character/product id, media inputs take upload_file handles. engine: local (default when every step is installed) | cloud | auto. Returns a runId; poll with wait_for_recipe_run or get_recipe_run. Cloud runs are priced up front and count against this agent\'s spend cap.',
388
+ inputSchema: {
389
+ id: z.string().describe('Recipe id from list_recipes'),
390
+ inputs: z.record(z.any()).describe('Input values keyed by the recipe\'s input keys (see list_recipes -> inputs).'),
391
+ engine: z.enum(['local', 'cloud', 'auto']).optional().describe('Where the steps render. auto = local if every step is installed, else cloud when connected.'),
392
+ },
393
+ },
394
+ wrap(({ id, inputs, engine }) => client.runRecipe(id, { inputs: inputs || {}, ...(engine ? { engine } : {}) })));
395
+
396
+ server.registerTool('get_recipe_run',
397
+ { title: 'Get a recipe run', description: 'Status of a recipe run: overall status (running | completed | failed | cancelled), each step with its status and resultPath, and resultPath = the final output once done.', inputSchema: { runId: z.string().describe('Run id from run_recipe') } },
398
+ wrap(({ runId }) => client.getRecipeRun(runId)));
399
+
400
+ server.registerTool('wait_for_recipe_run',
401
+ { title: 'Wait for a recipe run', description: 'Block until a recipe run completes, fails, is cancelled, or the timeout passes. Returns the final run (resultPath when completed). Multi-step video recipes can take several minutes; call again if it returns timeout_still_running.', inputSchema: { runId: z.string(), timeoutSeconds: z.number().optional(), pollMs: z.number().optional() } },
402
+ wrap(async ({ runId, timeoutSeconds = 600, pollMs = 3000 }) => {
403
+ const deadline = Date.now() + timeoutSeconds * 1000;
404
+ // eslint-disable-next-line no-constant-condition
405
+ while (true) {
406
+ const { run } = await client.getRecipeRun(runId);
407
+ if (run && ['completed', 'failed', 'cancelled'].includes(run.status)) return { run };
408
+ if (Date.now() >= deadline) return { run, note: 'timeout_still_running' };
409
+ await new Promise((r) => setTimeout(r, pollMs));
410
+ }
411
+ }));
412
+
413
+ server.registerTool('list_recipe_runs',
414
+ { title: 'List recipe runs', description: 'Recent runs of a recipe (newest first) with their status and results.', inputSchema: { id: z.string().describe('Recipe id') } },
415
+ wrap(({ id }) => client.listRecipeRuns(id)));
416
+
417
+ // --- Products + subjects: what a recipe's `subject` inputs take ---
418
+
419
+ server.registerTool('list_subjects',
420
+ { title: 'List subjects', description: 'Every subject a recipe\'s `subject` input can take: characters and products (id, type, name). Use a character id from list_characters or a product id from list_products / create_product.', inputSchema: {} },
421
+ wrap(() => client.listSubjects()));
422
+
423
+ server.registerTool('list_products',
424
+ { title: 'List products', description: 'The products saved on this device (a product = a named thing with a base photo, used by Product Studio and UGC recipes).', inputSchema: {} },
425
+ wrap(() => client.listProducts()));
426
+
427
+ server.registerTool('create_product',
428
+ {
429
+ title: 'Create a product',
430
+ description: 'Save a product from a photo so recipes can use it: upload the photo with upload_file first, then pass its handle. The description and category are woven into recipe prompts, so describe what it is and how it looks. Returns the productId to pass as a `subject` input in run_recipe.',
431
+ inputSchema: {
432
+ name: z.string().describe('Product name shown in the app'),
433
+ image: z.string().describe('upload_file handle of the product photo (clean, well-lit, product centred)'),
434
+ description: z.string().optional().describe('What it is and looks like, e.g. "a matte black ceramic coffee mug with a wooden lid"'),
435
+ category: z.string().optional().describe('e.g. skincare | beverage | electronics'),
436
+ },
437
+ },
438
+ wrap(({ name, image, description, category }) => client.createProduct({ name, image, description, category })));
439
+
440
+ server.registerTool('remove_product',
441
+ { title: 'Remove a product', description: 'Delete a product and its photo from this device.', inputSchema: { id: z.string() } },
442
+ wrap(({ id }) => client.removeProduct(id)));
443
+
444
+ server.registerTool('cancel_recipe_run',
445
+ { title: 'Cancel a recipe run', description: 'Stop a running recipe: no further steps start; a local in-flight step is interrupted (a cloud step already running finishes server-side and is saved).', inputSchema: { runId: z.string() } },
446
+ wrap(({ runId }) => client.cancelRecipeRun(runId)));
447
+
448
+ server.registerTool('get_recipe',
449
+ { title: 'Get a recipe', description: 'Get a saved recipe and its full config by id.', inputSchema: { id: z.string().describe('Recipe id from compose_recipe or list_recipes') } },
450
+ wrap(({ id }) => client.getRecipe(id)));
451
+
452
+ server.registerTool('publish_recipe',
453
+ { title: 'Publish a recipe', description: 'Submit a saved recipe to the SimpliGen Creator Marketplace (queued for human review). Requires a connected account: if none, returns not_connected and the user connects once in Settings, then this succeeds on the same recipe. priceTokens sets an optional one-time price (0 or omitted = free). Every preset the recipe uses must be a distributable first-party pack.', inputSchema: { id: z.string().describe('Recipe id to publish'), changelog: z.string().optional(), priceTokens: z.number().optional().describe('One-time price in tokens (0/omitted = free).') } },
454
+ wrap(({ id, changelog, priceTokens }) => client.publishRecipe(id, { changelog, priceTokens })));
455
+ }