@sogni-ai/sogni-creative-agent-skill 2.0.2 → 2.1.1

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/mcp-server.mjs DELETED
@@ -1,1665 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * sogni-agent MCP Server
4
- *
5
- * Exposes Sogni AI image/video generation as MCP tools for any MCP-compatible
6
- * agent (Claude Code, Claude Desktop, OpenClaw, Hermes Agent, Manus AI, etc.).
7
- * Wraps the sogni-agent CLI using its --json mode.
8
- *
9
- * Install (Claude Code):
10
- * claude mcp add sogni -- npx -y -p @sogni-ai/sogni-creative-agent-skill sogni-agent-mcp
11
- *
12
- * Install (Claude Desktop – add to claude_desktop_config.json):
13
- * { "mcpServers": { "sogni": { "command": "npx", "args": ["-y", "-p", "@sogni-ai/sogni-creative-agent-skill", "sogni-agent-mcp"] } } }
14
- */
15
-
16
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
17
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
19
- import { execaNode } from 'execa';
20
- import { fileURLToPath } from 'url';
21
- import { dirname, join } from 'path';
22
- import { existsSync, readFileSync } from 'fs';
23
- import { homedir } from 'os';
24
- import { getEnv, hasEnv } from './env.mjs';
25
- import { PACKAGE_VERSION } from './version.mjs';
26
-
27
- // ---------------------------------------------------------------------------
28
- // Paths
29
- // ---------------------------------------------------------------------------
30
-
31
- const __filename = fileURLToPath(import.meta.url);
32
- const __dirname = dirname(__filename);
33
- const SOGNI_AGENT = join(__dirname, 'sogni-agent.mjs');
34
- const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.config', 'sogni', 'credentials');
35
- const DEFAULT_DOWNLOADS_DIR = join(homedir(), 'Downloads', 'sogni');
36
- const CREDENTIALS_PATH = getEnv('SOGNI_CREDENTIALS_PATH', { trim: true }) || DEFAULT_CREDENTIALS_PATH;
37
- const DOWNLOADS_DIR = getEnv('SOGNI_DOWNLOADS_DIR', { trim: true }) || DEFAULT_DOWNLOADS_DIR;
38
- const MCP_SAVE_DOWNLOADS = getEnv('SOGNI_MCP_SAVE_DOWNLOADS') !== '0';
39
- const SERVER_VERSION = PACKAGE_VERSION;
40
- const DEFAULT_ALLOWED_DOWNLOAD_HOST_SUFFIXES = ['sogni.ai'];
41
- const ALLOWED_DOWNLOAD_HOST_SUFFIXES = (
42
- getEnv('SOGNI_ALLOWED_DOWNLOAD_HOSTS', { trim: true }) || ''
43
- )
44
- .split(',')
45
- .map((value) => value.trim().toLowerCase())
46
- .filter(Boolean);
47
-
48
- function isTrustedDownloadUrl(rawUrl) {
49
- try {
50
- const parsed = new URL(rawUrl);
51
- if (parsed.protocol !== 'https:') return false;
52
- const hostname = parsed.hostname.toLowerCase();
53
- const allowed = ALLOWED_DOWNLOAD_HOST_SUFFIXES.length > 0
54
- ? ALLOWED_DOWNLOAD_HOST_SUFFIXES
55
- : DEFAULT_ALLOWED_DOWNLOAD_HOST_SUFFIXES;
56
- return allowed.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
57
- } catch {
58
- return false;
59
- }
60
- }
61
-
62
- // ---------------------------------------------------------------------------
63
- // Input sanitization — validate MCP tool inputs before passing to CLI
64
- // ---------------------------------------------------------------------------
65
-
66
- /**
67
- * Reject null bytes and control characters in a string value.
68
- * Throws on invalid input; returns the string unchanged when valid.
69
- */
70
- function sanitizeString(value, label) {
71
- if (typeof value !== 'string') {
72
- throw new Error(`${label || 'Value'} must be a string.`);
73
- }
74
- if (value.includes('\0')) {
75
- throw new Error(`${label || 'Value'} contains a null byte.`);
76
- }
77
- if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) {
78
- throw new Error(`${label || 'Value'} contains invalid control characters.`);
79
- }
80
- return value;
81
- }
82
-
83
- /**
84
- * Validate a string is one of the allowed values (case-sensitive).
85
- */
86
- function validateEnum(value, allowed, label) {
87
- sanitizeString(value, label);
88
- if (!allowed.includes(value)) {
89
- throw new Error(`${label || 'Value'} must be one of: ${allowed.join(', ')}`);
90
- }
91
- return value;
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // CLI spawning helper
96
- // ---------------------------------------------------------------------------
97
-
98
- /**
99
- * Spawn `node sogni-agent.mjs --json ...args`, collect stdout, parse JSON.
100
- * Returns the parsed object on success or throws on failure.
101
- */
102
- function runSogniAgent(args, { timeoutMs = 30_000 } = {}) {
103
- return new Promise((resolve, reject) => {
104
- execaNode(SOGNI_AGENT, ['--json', '--quiet', ...args], {
105
- timeout: timeoutMs,
106
- reject: false,
107
- }).then(({ stdout, stderr, exitCode, timedOut }) => {
108
- const trimmedStdout = (stdout || '').trim();
109
- const trimmedStderr = (stderr || '').trim();
110
-
111
- if (!trimmedStdout) {
112
- if (timedOut) {
113
- reject(new Error(`sogni-agent timed out after ${timeoutMs}ms`));
114
- return;
115
- }
116
- reject(new Error(trimmedStderr || `sogni-agent exited with code ${exitCode} and no output`));
117
- return;
118
- }
119
-
120
- try {
121
- const result = JSON.parse(trimmedStdout);
122
- resolve(result);
123
- } catch {
124
- reject(new Error(`Failed to parse sogni-agent output: ${trimmedStdout.slice(0, 500)}`));
125
- }
126
- }).catch((err) => {
127
- reject(new Error(`Failed to execute sogni-agent: ${err.message}`));
128
- });
129
- });
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
- // Credential check helper
134
- // ---------------------------------------------------------------------------
135
-
136
- function checkCredentials() {
137
- if (existsSync(CREDENTIALS_PATH)) {
138
- try {
139
- const raw = readFileSync(CREDENTIALS_PATH, 'utf8');
140
- if (raw.includes('SOGNI_API_KEY=')) return null;
141
- if (raw.includes('SOGNI_USERNAME=') && raw.includes('SOGNI_PASSWORD=')) return null;
142
- } catch {
143
- // Fall through to env-based checks and error message.
144
- }
145
- }
146
- if (hasEnv('SOGNI_API_KEY')) return null;
147
- if (hasEnv('SOGNI_USERNAME') && hasEnv('SOGNI_PASSWORD')) return null;
148
- return {
149
- content: [
150
- {
151
- type: 'text',
152
- text: [
153
- 'Sogni credentials not found. Please set up credentials:',
154
- '',
155
- '1. Create a Sogni account at https://app.sogni.ai/',
156
- '2. Create the credentials file:',
157
- '',
158
- ' mkdir -p ~/.config/sogni',
159
- ' cat > ~/.config/sogni/credentials << \'EOF\'',
160
- ' SOGNI_API_KEY=your_api_key',
161
- ' # or use username/password instead:',
162
- ' # SOGNI_USERNAME=your_username',
163
- ' # SOGNI_PASSWORD=your_password',
164
- ' EOF',
165
- ' chmod 600 ~/.config/sogni/credentials',
166
- '',
167
- 'Or set SOGNI_API_KEY, or SOGNI_USERNAME and SOGNI_PASSWORD, as environment variables.',
168
- 'Optional: set SOGNI_CREDENTIALS_PATH to use a different credentials file path.',
169
- ].join('\n'),
170
- },
171
- ],
172
- isError: true,
173
- };
174
- }
175
-
176
- // ---------------------------------------------------------------------------
177
- // Result formatting
178
- // ---------------------------------------------------------------------------
179
-
180
- async function formatSuccess(result) {
181
- const parts = [];
182
-
183
- if (result.type === 'balance') {
184
- parts.push(`SPARK: ${result.spark ?? 'N/A'}`);
185
- parts.push(`SOGNI: ${result.sogni ?? 'N/A'}`);
186
- return { content: [{ type: 'text', text: parts.join('\n') }] };
187
- }
188
-
189
- // Image / video result
190
- if (result.prompt) parts.push(`Prompt: ${result.prompt}`);
191
- parts.push(`Model: ${result.model}`);
192
- parts.push(`Size: ${result.width}x${result.height}`);
193
- if (result.seed != null) parts.push(`Seed: ${result.seed}`);
194
-
195
- if (result.type === 'video') {
196
- if (result.workflow) parts.push(`Workflow: ${result.workflow}`);
197
- if (result.duration) parts.push(`Duration: ${result.duration}s`);
198
- if (result.fps) parts.push(`FPS: ${result.fps}`);
199
- }
200
-
201
- if (result.localPath) parts.push(`Saved to: ${result.localPath}`);
202
-
203
- // URLs
204
- const urls = result.urls || [];
205
- if (urls.length > 0) {
206
- parts.push('');
207
- urls.forEach((url, i) => {
208
- parts.push(urls.length === 1 ? `URL: ${url}` : `URL #${i + 1}: ${url}`);
209
- });
210
- }
211
-
212
- const content = [{ type: 'text', text: parts.join('\n') }];
213
-
214
- // Download images/videos and save locally + embed as base64 for MCP clients
215
- // that support inline image rendering (e.g. Claude Desktop).
216
- // For Claude Code (terminal), the saved file path is the primary way to view results.
217
- const savedPaths = [];
218
- for (const url of urls) {
219
- const isImage = /\.(png|jpg|jpeg|webp|gif)(\?|$)/i.test(url);
220
- const isVideo = /\.(mp4|webm|mov)(\?|$)/i.test(url);
221
-
222
- if (!isImage && !isVideo) continue;
223
- if (!isTrustedDownloadUrl(url)) {
224
- parts.push(`Skipped local download for untrusted host: ${url}`);
225
- continue;
226
- }
227
-
228
- try {
229
- const resp = await fetch(url);
230
- if (!resp.ok) continue;
231
- const buf = Buffer.from(await resp.arrayBuffer());
232
-
233
- // Determine extension and build a temp file path
234
- const ext = isImage
235
- ? (url.match(/\.(png|jpg|jpeg|webp|gif)/i)?.[1]?.toLowerCase() || 'png')
236
- : (url.match(/\.(mp4|webm|mov)/i)?.[1]?.toLowerCase() || 'mp4');
237
-
238
- // Save to local disk (default: ~/Downloads/sogni) so terminal users can open files.
239
- if (MCP_SAVE_DOWNLOADS) {
240
- const { mkdirSync, writeFileSync } = await import('fs');
241
- mkdirSync(DOWNLOADS_DIR, { recursive: true });
242
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
243
- const filename = `sogni-${timestamp}-${savedPaths.length}.${ext}`;
244
- const filePath = join(DOWNLOADS_DIR, filename);
245
- writeFileSync(filePath, buf);
246
- savedPaths.push(filePath);
247
- }
248
-
249
- // For images, also embed as base64 (Claude Desktop can render these)
250
- if (isImage) {
251
- const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
252
- : ext === 'webp' ? 'image/webp'
253
- : ext === 'gif' ? 'image/gif'
254
- : 'image/png';
255
- content.push({ type: 'image', data: buf.toString('base64'), mimeType });
256
- }
257
- } catch {
258
- // If download fails, skip — the URL is still in the text above
259
- }
260
- }
261
-
262
- // Append saved file paths to the text output so Claude Code users can see/open them
263
- if (savedPaths.length > 0) {
264
- const textBlock = content[0];
265
- textBlock.text += '\n\n' + savedPaths.map((p, i) =>
266
- savedPaths.length === 1 ? `📁 Saved: ${p}` : `📁 Saved #${i + 1}: ${p}`
267
- ).join('\n');
268
- textBlock.text += '\n\nTip: In Claude Code, ask Claude to run `open <path>` to view the file.';
269
- }
270
-
271
- return { content };
272
- }
273
-
274
- function formatError(result) {
275
- const parts = [`Error: ${result.error}`];
276
- if (result.errorCode) parts.push(`Code: ${result.errorCode}`);
277
- if (result.hint) parts.push(`Hint: ${result.hint}`);
278
- return { content: [{ type: 'text', text: parts.join('\n') }], isError: true };
279
- }
280
-
281
- async function formatResult(result) {
282
- if (result.success === false) return formatError(result);
283
- return formatSuccess(result);
284
- }
285
-
286
- async function runAndFormat(args, { timeoutMs = 30_000, requireCredentials = true } = {}) {
287
- if (requireCredentials) {
288
- const credErr = checkCredentials();
289
- if (credErr) return credErr;
290
- }
291
- const result = await runSogniAgent(args, { timeoutMs });
292
- return formatResult(result);
293
- }
294
-
295
- // ---------------------------------------------------------------------------
296
- // Tool definitions
297
- // ---------------------------------------------------------------------------
298
-
299
- const IMAGE_MODEL_TABLE = `Image Models:
300
- z_image_turbo_bf16 — Fast (~5-10s), general purpose (default)
301
- flux1-schnell-fp8 — Very fast (~3-5s), quick iterations
302
- flux2_dev_fp8 — Slow (~2min), high quality
303
- chroma-v.46-flash_fp8 — Medium (~30s), balanced
304
- qwen_image_edit_2511_fp8 — Medium (~30s), image editing with context
305
- qwen_image_edit_2511_fp8_lightning — Fast (~8s), quick image editing`;
306
-
307
- const VIDEO_MODEL_TABLE = `Recommended Video Models:
308
- ltx23-22b-fp8_t2v_distilled — LTX 2.3 text-to-video with native dialogue/audio (~2-3min)
309
- ltx23-22b-fp8_i2v_distilled — LTX 2.3 image-to-video with native dialogue/audio (~2-3min)
310
- ltx23-22b-fp8_ia2v_distilled — LTX 2.3 image+audio-to-video (~2-3min)
311
- ltx23-22b-fp8_a2v_distilled — LTX 2.3 audio-to-video (~2-3min)
312
- ltx23-22b-fp8_v2v_distilled — LTX 2.3 video-to-video with ControlNet (~3min)
313
-
314
- Seedance 2.0 Aliases:
315
- seedance2 — Text-to-video, 4-15s, native audio
316
- seedance2-fast — Fast 720p-capped text-to-video
317
- seedance2-ia2v — Image+audio-to-video
318
- seedance2-v2v — Video-to-video without ControlNet
319
-
320
- WAN 2.2 Video Models:
321
- wan_v2.2-14b-fp8_t2v_lightx2v — Text-to-video (~5min)
322
- wan_v2.2-14b-fp8_i2v_lightx2v — Image-to-video (~3-5min)
323
- wan_v2.2-14b-fp8_s2v_lightx2v — Lip-sync with face image + uploaded audio (~5min)
324
- wan_v2.2-14b-fp8_animate-move_lightx2v — Animate-move (~5min)
325
- wan_v2.2-14b-fp8_animate-replace_lightx2v — Animate-replace (~5min)
326
-
327
- Legacy LTX-2 Video Models:
328
- ltx2-19b-fp8_t2v_distilled — Text-to-video, fast 8-step (~2-3min)
329
- ltx2-19b-fp8_t2v — Text-to-video, quality 20-step (~5min)
330
- ltx2-19b-fp8_i2v_distilled — Image-to-video, fast 8-step (~2-3min)
331
- ltx2-19b-fp8_i2v — Image-to-video, quality 20-step (~5min)
332
- ltx2-19b-fp8_ia2v_distilled — Image+audio-to-video, fast 8-step (~2-3min)
333
- ltx2-19b-fp8_a2v_distilled — Audio-to-video, fast 8-step (~2-3min)
334
- ltx2-19b-fp8_v2v_distilled — Video-to-video with ControlNet (~3min)
335
- ltx2-19b-fp8_v2v — Video-to-video with ControlNet, quality (~5min)`;
336
-
337
- const TOOLS = [
338
- {
339
- name: 'generate_image',
340
- description: `Generate an image using Sogni AI's decentralized GPU network.
341
-
342
- Quality presets (recommended — auto-selects model, steps, and dimensions):
343
- fast — z_image_turbo_bf16, 8 steps, 512x512 (default, ~5-10s)
344
- hq — z_image_turbo_bf16, default steps, 768x768 (~10-15s)
345
- pro — flux2_dev_fp8, 40 steps, 1024x1024 (~2min, highest quality)
346
-
347
- ${IMAGE_MODEL_TABLE}
348
-
349
- Prompt variations: Use {option1|option2|option3} syntax with count > 1 to generate diverse images in one call.
350
- Example: prompt="a {red|blue|green} car", count=3 → generates one of each color.
351
-
352
- Cost: Uses Spark tokens. 512x512 is most cost-efficient. Claim 50 free daily Spark at https://app.sogni.ai/`,
353
- inputSchema: {
354
- type: 'object',
355
- properties: {
356
- prompt: {
357
- type: 'string',
358
- description: 'Image description / generation prompt. Supports {a|b|c} variation syntax with count > 1.',
359
- },
360
- quality: {
361
- type: 'string',
362
- enum: ['fast', 'hq', 'pro'],
363
- description: 'Quality preset (auto-selects model/steps/size). Overridden by explicit model param.',
364
- },
365
- model: {
366
- type: 'string',
367
- description: 'Model ID (default: z_image_turbo_bf16). Overrides quality preset.',
368
- },
369
- width: {
370
- type: 'number',
371
- description: 'Image width in pixels (default: 512)',
372
- },
373
- height: {
374
- type: 'number',
375
- description: 'Image height in pixels (default: 512)',
376
- },
377
- count: {
378
- type: 'number',
379
- description: 'Number of images to generate (default: 1)',
380
- },
381
- seed: {
382
- type: 'number',
383
- description: 'Specific seed for reproducibility',
384
- },
385
- output: {
386
- type: 'string',
387
- description: 'Save image to this file path',
388
- },
389
- output_format: {
390
- type: 'string',
391
- enum: ['png', 'jpg'],
392
- description: 'Output format (default: png)',
393
- },
394
- loras: {
395
- type: 'array',
396
- items: { type: 'string' },
397
- description: 'LoRA model IDs',
398
- },
399
- lora_strengths: {
400
- type: 'array',
401
- items: { type: 'number' },
402
- description: 'LoRA strengths (parallel to loras array)',
403
- },
404
- },
405
- required: ['prompt'],
406
- },
407
- },
408
- {
409
- name: 'generate_video',
410
- description: `Generate a video using Sogni AI's decentralized GPU network.
411
-
412
- Workflows:
413
- t2v — Text-to-video. LTX 2.3 default, supports native dialogue/audio from the prompt.
414
- i2v — Image-to-video. Provide ref. Use LTX 2.3 for dialogue/audio/story prompts.
415
- s2v — WAN lip-sync. Provide face ref + ref_audio only when explicitly syncing lips.
416
- ia2v — LTX 2.3 image+audio-to-video. Provide ref + uploaded/generated ref_audio.
417
- a2v — LTX 2.3 audio-to-video. Provide ref_audio only.
418
- v2v — LTX 2.3 video-to-video. Provide ref_video + controlnet_name.
419
- animate-move — Transfer motion from ref_video to ref image.
420
- animate-replace — Replace subject in ref_video with ref image.
421
-
422
- Routing rules:
423
- - If the user asks for a story, narration, dialogue, sound effects, ambient audio, or music generated from the prompt, use LTX 2.3 and put exact spoken words in double quotes.
424
- - If the user provides an uploaded/generated audio file and a reference image, omit workflow and the CLI will auto-select ia2v; use workflow=s2v only for explicit face lip-sync.
425
- - For persona voice cloning, set voice_persona or reference_audio_identity. Do not pass that clip as ref_audio.
426
-
427
- ${VIDEO_MODEL_TABLE}
428
-
429
- WAN dimensions: divisible by 16, min 480px, max 1536px. LTX 2.3 dimensions: divisible by 64, min 640px, max 3840px.
430
- Generation takes 3-5 minutes. Cost: Uses Spark tokens. Claim 50 free daily Spark at https://app.sogni.ai/`,
431
- inputSchema: {
432
- type: 'object',
433
- properties: {
434
- prompt: {
435
- type: 'string',
436
- description: 'Video description / generation prompt',
437
- },
438
- workflow: {
439
- type: 'string',
440
- enum: ['t2v', 'i2v', 's2v', 'ia2v', 'a2v', 'v2v', 'animate-move', 'animate-replace'],
441
- description: 'Video workflow (default: t2v, auto-inferred from provided refs)',
442
- },
443
- quality: {
444
- type: 'string',
445
- enum: ['fast', 'hq', 'pro'],
446
- description: 'Video quality preset. hq/pro prefer LTX for i2v and adjust steps/resolution unless explicit values are provided.',
447
- },
448
- model: {
449
- type: 'string',
450
- description: 'Model ID or alias (ltx23, ltx23-i2v, ltx23-ia2v, ltx23-a2v, ltx23-v2v, wan22-s2v). Overrides quality/default routing.',
451
- },
452
- width: {
453
- type: 'number',
454
- description: 'Video width in pixels. Defaults and divisibility are model-specific.',
455
- },
456
- height: {
457
- type: 'number',
458
- description: 'Video height in pixels. Defaults and divisibility are model-specific.',
459
- },
460
- fps: {
461
- type: 'number',
462
- description: 'Frames per second (default: 16)',
463
- },
464
- duration: {
465
- type: 'number',
466
- description: 'Duration in seconds (default: 5)',
467
- },
468
- frames: {
469
- type: 'number',
470
- description: 'Override total frame count (alternative to duration)',
471
- },
472
- target_resolution: {
473
- type: 'number',
474
- description: 'Short-side video resolution target in pixels; preserves aspect ratio when width/height are not set.',
475
- },
476
- ref: {
477
- type: 'string',
478
- description: 'Reference image path or URL (for i2v, s2v, animate workflows)',
479
- },
480
- ref_end: {
481
- type: 'string',
482
- description: 'End frame image path or URL (for i2v interpolation)',
483
- },
484
- ref_audio: {
485
- type: 'string',
486
- description: 'Uploaded/generated audio file path. With ref it auto-routes to LTX ia2v unless workflow=s2v is explicitly set for lip-sync.',
487
- },
488
- audio_start: {
489
- type: 'number',
490
- description: 'Optional start offset in seconds into ref_audio for audio-driven video.',
491
- },
492
- audio_duration: {
493
- type: 'number',
494
- description: 'Optional audio slice duration in seconds for audio-driven video.',
495
- },
496
- reference_audio_identity: {
497
- type: 'string',
498
- description: 'Voice identity clip for LTX native audio/persona voice cloning. Do not use for uploaded songs or soundtracks.',
499
- },
500
- voice_persona: {
501
- type: 'string',
502
- description: 'Saved persona name whose voice clip should be used as LTX referenceAudioIdentity.',
503
- },
504
- ref_video: {
505
- type: 'string',
506
- description: 'Reference video file path (for animate and v2v workflows)',
507
- },
508
- video_start: {
509
- type: 'number',
510
- description: 'Optional start offset in seconds into ref_video for segmented V2V/animate workflows.',
511
- },
512
- controlnet_name: {
513
- type: 'string',
514
- enum: ['canny', 'pose', 'depth', 'detailer'],
515
- description: 'ControlNet type for v2v workflow',
516
- },
517
- controlnet_strength: {
518
- type: 'number',
519
- description: 'ControlNet strength for v2v (0.0-1.0, default: 0.8)',
520
- },
521
- sam2_coordinates: {
522
- type: 'string',
523
- description: 'SAM2 click coordinates for animate-replace (x,y or x1,y1;x2,y2)',
524
- },
525
- trim_end_frame: {
526
- type: 'boolean',
527
- description: 'Trim last frame for seamless video stitching',
528
- },
529
- first_frame_strength: {
530
- type: 'number',
531
- description: 'Keyframe strength for start frame (0.0-1.0)',
532
- },
533
- last_frame_strength: {
534
- type: 'number',
535
- description: 'Keyframe strength for end frame (0.0-1.0)',
536
- },
537
- seed: {
538
- type: 'number',
539
- description: 'Specific seed for reproducibility',
540
- },
541
- output: {
542
- type: 'string',
543
- description: 'Save video to this file path',
544
- },
545
- looping: {
546
- type: 'boolean',
547
- description: 'Generate seamless loop (i2v only)',
548
- },
549
- },
550
- required: ['prompt'],
551
- },
552
- },
553
- {
554
- name: 'animate_photo',
555
- description: `Animate a reference image into video. Use this instead of generate_video when an image/photo already exists.
556
-
557
- Use LTX 2.3 for most animation, dialogue, story, and native audio requests. If the user provides an uploaded audio file, use sound_to_video instead. For dialogue, include exact spoken words in double quotes.`,
558
- inputSchema: {
559
- type: 'object',
560
- properties: {
561
- prompt: { type: 'string', description: 'Motion, camera, dialogue, and audio prompt. Spoken lines must be in double quotes.' },
562
- ref: { type: 'string', description: 'Reference image path or URL to animate.' },
563
- ref_end: { type: 'string', description: 'Optional end-frame image for a first/last-frame transition.' },
564
- quality: { type: 'string', enum: ['fast', 'hq', 'pro'], description: 'Quality preset.' },
565
- model: { type: 'string', description: 'Optional model override, e.g. ltx23-i2v or wan22-i2v.' },
566
- width: { type: 'number', description: 'Video width.' },
567
- height: { type: 'number', description: 'Video height.' },
568
- duration: { type: 'number', description: 'Duration in seconds.' },
569
- fps: { type: 'number', description: 'Frames per second.' },
570
- target_resolution: { type: 'number', description: 'Short-side resolution target.' },
571
- reference_audio_identity: { type: 'string', description: 'Voice identity clip for persona voice cloning with LTX native audio.' },
572
- voice_persona: { type: 'string', description: 'Saved persona name whose voice clip should be used for LTX voice identity.' },
573
- seed: { type: 'number', description: 'Seed for reproducibility.' },
574
- output: { type: 'string', description: 'Save video to this file path.' },
575
- looping: { type: 'boolean', description: 'Generate seamless loop.' },
576
- },
577
- required: ['prompt', 'ref'],
578
- },
579
- },
580
- {
581
- name: 'sound_to_video',
582
- description: `Generate video synchronized to an uploaded or generated audio file.
583
-
584
- With ref image, defaults to LTX 2.3 ia2v. Without ref image, defaults to LTX 2.3 a2v. Set workflow=s2v only for explicit face lip-sync with a face image.`,
585
- inputSchema: {
586
- type: 'object',
587
- properties: {
588
- prompt: { type: 'string', description: 'Video description.' },
589
- ref_audio: { type: 'string', description: 'Audio file path to drive/synchronize the video.' },
590
- ref: { type: 'string', description: 'Optional reference image path or URL.' },
591
- workflow: { type: 'string', enum: ['ia2v', 'a2v', 's2v'], description: 'Optional override. Omit for automatic LTX routing.' },
592
- quality: { type: 'string', enum: ['fast', 'hq', 'pro'], description: 'Quality preset.' },
593
- model: { type: 'string', description: 'Optional model override.' },
594
- width: { type: 'number', description: 'Video width.' },
595
- height: { type: 'number', description: 'Video height.' },
596
- duration: { type: 'number', description: 'Duration in seconds.' },
597
- fps: { type: 'number', description: 'Frames per second.' },
598
- target_resolution: { type: 'number', description: 'Short-side resolution target.' },
599
- audio_start: { type: 'number', description: 'Optional start offset in seconds into ref_audio.' },
600
- audio_duration: { type: 'number', description: 'Optional duration slice in seconds from ref_audio.' },
601
- seed: { type: 'number', description: 'Seed for reproducibility.' },
602
- output: { type: 'string', description: 'Save video to this file path.' },
603
- },
604
- required: ['prompt', 'ref_audio'],
605
- },
606
- },
607
- {
608
- name: 'video_to_video',
609
- description: 'Transform an existing video with LTX 2.3 V2V ControlNet, or model=seedance2-v2v for a no-ControlNet Seedance V2V transform. Use canny for edges, pose for motion/body structure, depth for spatial layout, or detailer for detail-preserving LTX transforms.',
610
- inputSchema: {
611
- type: 'object',
612
- properties: {
613
- prompt: { type: 'string', description: 'Transformation prompt.' },
614
- ref_video: { type: 'string', description: 'Source video path.' },
615
- controlnet_name: { type: 'string', enum: ['canny', 'pose', 'depth', 'detailer'], description: 'ControlNet type.' },
616
- controlnet_strength: { type: 'number', description: 'ControlNet strength, 0.0-1.0.' },
617
- quality: { type: 'string', enum: ['fast', 'hq', 'pro'], description: 'Quality preset.' },
618
- model: { type: 'string', description: 'Optional model override.' },
619
- width: { type: 'number', description: 'Video width.' },
620
- height: { type: 'number', description: 'Video height.' },
621
- duration: { type: 'number', description: 'Duration in seconds.' },
622
- fps: { type: 'number', description: 'Frames per second.' },
623
- target_resolution: { type: 'number', description: 'Short-side resolution target.' },
624
- video_start: { type: 'number', description: 'Optional start offset in seconds into ref_video for segmented transformations.' },
625
- seed: { type: 'number', description: 'Seed for reproducibility.' },
626
- output: { type: 'string', description: 'Save video to this file path.' },
627
- },
628
- required: ['prompt', 'ref_video'],
629
- },
630
- },
631
- {
632
- name: 'edit_image',
633
- description: `Edit or transform an existing image using Sogni AI (Qwen image editing models).
634
-
635
- Provide 1-3 context images and a prompt describing the desired edit. Examples:
636
- - "make the background a beach"
637
- - "apply pop art style"
638
- - "remove the person on the left"
639
- - "add a rainbow in the sky"
640
-
641
- Models:
642
- qwen_image_edit_2511_fp8_lightning — Fast (~8s), default
643
- qwen_image_edit_2511_fp8 — Medium (~30s), higher quality`,
644
- inputSchema: {
645
- type: 'object',
646
- properties: {
647
- prompt: {
648
- type: 'string',
649
- description: 'Editing instruction describing the desired change',
650
- },
651
- context_images: {
652
- type: 'array',
653
- items: { type: 'string' },
654
- description: 'Image file paths or URLs to edit (1-3 images)',
655
- minItems: 1,
656
- maxItems: 3,
657
- },
658
- model: {
659
- type: 'string',
660
- description: 'Model ID (default: qwen_image_edit_2511_fp8_lightning)',
661
- },
662
- width: {
663
- type: 'number',
664
- description: 'Output width in pixels',
665
- },
666
- height: {
667
- type: 'number',
668
- description: 'Output height in pixels',
669
- },
670
- output: {
671
- type: 'string',
672
- description: 'Save edited image to this file path',
673
- },
674
- },
675
- required: ['prompt', 'context_images'],
676
- },
677
- },
678
- {
679
- name: 'photobooth',
680
- description: `Generate stylized portraits from a face photo using InstantID face transfer.
681
-
682
- Provide a face reference image and a style prompt. Examples:
683
- - "80s fashion portrait"
684
- - "LinkedIn professional headshot"
685
- - "oil painting Renaissance style"
686
- - "anime character"
687
-
688
- Uses SDXL Turbo (coreml-sogniXLturbo_alpha1_ad) at 1024x1024 by default.
689
- The face likeness is preserved while applying the style from the prompt.`,
690
- inputSchema: {
691
- type: 'object',
692
- properties: {
693
- prompt: {
694
- type: 'string',
695
- description: 'Style/scene description for the portrait',
696
- },
697
- reference_face: {
698
- type: 'string',
699
- description: 'Face image file path or URL',
700
- },
701
- model: {
702
- type: 'string',
703
- description: 'Model ID (default: coreml-sogniXLturbo_alpha1_ad)',
704
- },
705
- cn_strength: {
706
- type: 'number',
707
- description: 'ControlNet strength — higher = more face likeness (default: 0.8)',
708
- },
709
- cn_guidance_end: {
710
- type: 'number',
711
- description: 'ControlNet guidance end point (default: 0.3)',
712
- },
713
- width: {
714
- type: 'number',
715
- description: 'Output width in pixels (default: 1024)',
716
- },
717
- height: {
718
- type: 'number',
719
- description: 'Output height in pixels (default: 1024)',
720
- },
721
- count: {
722
- type: 'number',
723
- description: 'Number of images to generate (default: 1)',
724
- },
725
- output: {
726
- type: 'string',
727
- description: 'Save image to this file path',
728
- },
729
- },
730
- required: ['prompt', 'reference_face'],
731
- },
732
- },
733
- {
734
- name: 'check_balance',
735
- description:
736
- 'Check your current Sogni token balances (SPARK and SOGNI). Free daily Spark tokens can be claimed at https://app.sogni.ai/',
737
- inputSchema: {
738
- type: 'object',
739
- properties: {},
740
- },
741
- },
742
- {
743
- name: 'list_models',
744
- description:
745
- 'List all available Sogni AI models for image generation, image editing, photobooth, and video generation with speed estimates.',
746
- inputSchema: {
747
- type: 'object',
748
- properties: {},
749
- },
750
- },
751
- {
752
- name: 'get_version',
753
- description: 'Show the running sogni-agent version for this MCP server instance.',
754
- inputSchema: {
755
- type: 'object',
756
- properties: {},
757
- },
758
- },
759
- {
760
- name: 'extract_last_frame',
761
- description: 'Extract the last frame from a video file as an image. Safe ffmpeg wrapper with input sanitization.',
762
- inputSchema: {
763
- type: 'object',
764
- properties: {
765
- video_path: {
766
- type: 'string',
767
- description: 'Path to the source video file',
768
- },
769
- output_path: {
770
- type: 'string',
771
- description: 'Path to save the extracted frame image (e.g. /tmp/lastframe.png)',
772
- },
773
- },
774
- required: ['video_path', 'output_path'],
775
- },
776
- },
777
- {
778
- name: 'concat_videos',
779
- description: 'Concatenate multiple video clips into a single video file. Safe ffmpeg wrapper with input sanitization. Requires at least 2 clips.',
780
- inputSchema: {
781
- type: 'object',
782
- properties: {
783
- output_path: {
784
- type: 'string',
785
- description: 'Path for the concatenated output video',
786
- },
787
- clips: {
788
- type: 'array',
789
- items: { type: 'string' },
790
- description: 'Array of video clip file paths to concatenate (minimum 2)',
791
- minItems: 2,
792
- },
793
- audio_path: {
794
- type: 'string',
795
- description: 'Optional audio file to mux over the stitched video.',
796
- },
797
- audio_start: {
798
- type: 'number',
799
- description: 'Optional start offset in seconds into audio_path.',
800
- },
801
- },
802
- required: ['output_path', 'clips'],
803
- },
804
- },
805
- {
806
- name: 'stitch_video',
807
- description: 'Stitch multiple completed video clips into one video. Alias of concat_videos using the safe ffmpeg wrapper.',
808
- inputSchema: {
809
- type: 'object',
810
- properties: {
811
- output_path: {
812
- type: 'string',
813
- description: 'Path for the stitched output video',
814
- },
815
- clips: {
816
- type: 'array',
817
- items: { type: 'string' },
818
- description: 'Video clip file paths in final order (minimum 2)',
819
- minItems: 2,
820
- },
821
- audio_path: {
822
- type: 'string',
823
- description: 'Optional audio file to mux over the stitched video.',
824
- },
825
- audio_start: {
826
- type: 'number',
827
- description: 'Optional start offset in seconds into audio_path.',
828
- },
829
- },
830
- required: ['output_path', 'clips'],
831
- },
832
- },
833
- {
834
- name: 'list_media',
835
- description: 'List recent inbound media files (images, audio, or all) from the user media directory (~/.clawdbot/media/inbound/). Returns the 5 most recent files sorted by modification time.',
836
- inputSchema: {
837
- type: 'object',
838
- properties: {
839
- type: {
840
- type: 'string',
841
- enum: ['images', 'audio', 'all'],
842
- description: 'Type of media to list (default: images)',
843
- },
844
- },
845
- },
846
- },
847
- {
848
- name: 'refine_result',
849
- description: `Re-run the last generation with tweaked parameters. Reads the last render metadata and lets you override specific settings while keeping everything else the same.
850
-
851
- Use this to:
852
- - Bump quality: refine_result with quality="pro" to re-render at higher quality
853
- - Try a different model: refine_result with model="flux2_dev_fp8"
854
- - Lock a seed: refine_result with seed=12345
855
- - Tweak the prompt: refine_result with prompt="..."
856
- - Change dimensions: refine_result with width=1024, height=1024
857
-
858
- Requires a previous generation in this session (reads ~/.config/sogni/last-render.json).`,
859
- inputSchema: {
860
- type: 'object',
861
- properties: {
862
- prompt: {
863
- type: 'string',
864
- description: 'Override the prompt (default: reuse last prompt)',
865
- },
866
- quality: {
867
- type: 'string',
868
- enum: ['fast', 'hq', 'pro'],
869
- description: 'Quality preset override',
870
- },
871
- model: {
872
- type: 'string',
873
- description: 'Model override',
874
- },
875
- width: {
876
- type: 'number',
877
- description: 'Width override',
878
- },
879
- height: {
880
- type: 'number',
881
- description: 'Height override',
882
- },
883
- seed: {
884
- type: 'number',
885
- description: 'Seed override (use to lock the composition)',
886
- },
887
- count: {
888
- type: 'number',
889
- description: 'Number of images override',
890
- },
891
- },
892
- },
893
- },
894
- {
895
- name: 'estimate_cost',
896
- description: `Estimate the cost of a generation before running it. Returns estimated cost in SPARK and SOGNI tokens.
897
-
898
- For video: requires model, width, height, fps, steps, and duration/frames.
899
- For images: returns a rough cost based on model and dimensions.
900
-
901
- Use this before expensive generations (pro quality, large videos) to check if the user has enough tokens.`,
902
- inputSchema: {
903
- type: 'object',
904
- properties: {
905
- type: {
906
- type: 'string',
907
- enum: ['image', 'video'],
908
- description: 'Generation type (default: image)',
909
- },
910
- model: {
911
- type: 'string',
912
- description: 'Model ID',
913
- },
914
- width: {
915
- type: 'number',
916
- description: 'Width in pixels',
917
- },
918
- height: {
919
- type: 'number',
920
- description: 'Height in pixels',
921
- },
922
- steps: {
923
- type: 'number',
924
- description: 'Number of steps (required for video cost estimation)',
925
- },
926
- duration: {
927
- type: 'number',
928
- description: 'Duration in seconds (video only)',
929
- },
930
- fps: {
931
- type: 'number',
932
- description: 'Frames per second (video only, default: 16)',
933
- },
934
- count: {
935
- type: 'number',
936
- description: 'Number of outputs (default: 1)',
937
- },
938
- },
939
- },
940
- },
941
- {
942
- name: 'manage_memory',
943
- description: `Manage persistent user preferences that are respected across sessions. Memories are stored locally on the user's machine at ~/.config/sogni/memories.json.
944
-
945
- Use this to remember and recall user preferences like preferred style, aspect ratio, favorite artists, or any other context that should persist.
946
-
947
- Actions:
948
- read — List all saved memories (or get one by key)
949
- write — Save or update a memory (upsert by key)
950
- delete — Remove a memory by key
951
-
952
- Always check memories before generating to respect saved preferences.`,
953
- inputSchema: {
954
- type: 'object',
955
- properties: {
956
- action: {
957
- type: 'string',
958
- enum: ['read', 'write', 'delete'],
959
- description: 'CRUD action',
960
- },
961
- key: {
962
- type: 'string',
963
- description: 'Memory key (required for write/delete, optional for read to get one specific memory)',
964
- },
965
- value: {
966
- type: 'string',
967
- description: 'Memory value (required for write)',
968
- },
969
- category: {
970
- type: 'string',
971
- enum: ['preference', 'fact', 'context'],
972
- description: 'Memory category (default: preference)',
973
- },
974
- },
975
- required: ['action'],
976
- },
977
- },
978
- {
979
- name: 'manage_personality',
980
- description: `Manage custom personality instructions that shape how the agent behaves. Stored at ~/.config/sogni/personality.txt.
981
-
982
- Actions:
983
- get — Read current personality instructions
984
- set — Save new personality instructions
985
- clear — Reset to default personality
986
-
987
- Example personalities: "Be concise, skip small talk", "Always suggest cinematic lighting", "Use a warm and encouraging tone"`,
988
- inputSchema: {
989
- type: 'object',
990
- properties: {
991
- action: {
992
- type: 'string',
993
- enum: ['get', 'set', 'clear'],
994
- description: 'Action to perform',
995
- },
996
- text: {
997
- type: 'string',
998
- description: 'Personality instructions (required for set)',
999
- },
1000
- },
1001
- required: ['action'],
1002
- },
1003
- },
1004
- {
1005
- name: 'manage_personas',
1006
- description: `Manage named personas — people with saved reference photos and optional voice clips for identity-preserving generation. Stored at ~/.config/sogni/personas/.
1007
-
1008
- Actions:
1009
- list — List all saved personas
1010
- add — Add a new persona with a reference photo
1011
- remove — Remove a persona and its files
1012
- resolve — Look up a persona by name, tag, or relationship pronoun ("me", "my wife", etc.)
1013
-
1014
- Personas enable identity-preserving generation: "generate me as a superhero" works because the agent knows who "me" is and has their reference photo.
1015
-
1016
- For video with voice cloning, provide a voice_clip_path when adding the persona.`,
1017
- inputSchema: {
1018
- type: 'object',
1019
- properties: {
1020
- action: {
1021
- type: 'string',
1022
- enum: ['list', 'add', 'remove', 'resolve'],
1023
- description: 'CRUD action',
1024
- },
1025
- name: {
1026
- type: 'string',
1027
- description: 'Persona name (required for add/remove/resolve)',
1028
- },
1029
- photo_path: {
1030
- type: 'string',
1031
- description: 'Path to reference photo (required for add)',
1032
- },
1033
- relationship: {
1034
- type: 'string',
1035
- enum: ['self', 'partner', 'child', 'friend', 'pet'],
1036
- description: 'Relationship to user (default: friend). "self" enables "me"/"myself" pronoun matching.',
1037
- },
1038
- description: {
1039
- type: 'string',
1040
- description: 'Appearance description for prompt engineering',
1041
- },
1042
- tags: {
1043
- type: 'array',
1044
- items: { type: 'string' },
1045
- description: 'Nicknames or aliases for matching',
1046
- },
1047
- voice: {
1048
- type: 'string',
1049
- description: 'Voice description (accent, tone, pitch) for prompt engineering',
1050
- },
1051
- voice_clip_path: {
1052
- type: 'string',
1053
- description: 'Path to voice clip audio file for LTX-2.3 voice cloning',
1054
- },
1055
- },
1056
- required: ['action'],
1057
- },
1058
- },
1059
- {
1060
- name: 'apply_style',
1061
- description: `Apply an artistic style to an image. Wraps image editing with style-specific prompt engineering.
1062
-
1063
- Reference artists and styles BY NAME for best results:
1064
- - "Andy Warhol pop art"
1065
- - "Studio Ghibli watercolor"
1066
- - "Banksy street art"
1067
- - "oil painting in the style of Vermeer"
1068
- - "cyberpunk neon aesthetic"
1069
-
1070
- For photos with people, the prompt should include "Preserve all facial features, expressions, and identity."`,
1071
- inputSchema: {
1072
- type: 'object',
1073
- properties: {
1074
- prompt: {
1075
- type: 'string',
1076
- description: 'Style description (reference artists BY NAME for best results)',
1077
- },
1078
- source_image: {
1079
- type: 'string',
1080
- description: 'Path to image to stylize',
1081
- },
1082
- model: {
1083
- type: 'string',
1084
- description: 'Model override (default: qwen_image_edit_2511_fp8_lightning)',
1085
- },
1086
- width: {
1087
- type: 'number',
1088
- description: 'Output width',
1089
- },
1090
- height: {
1091
- type: 'number',
1092
- description: 'Output height',
1093
- },
1094
- },
1095
- required: ['prompt', 'source_image'],
1096
- },
1097
- },
1098
- {
1099
- name: 'change_angle',
1100
- description: `Generate a photo from a different camera angle using Qwen + Multiple Angles LoRA.
1101
-
1102
- Azimuth options: front, front-right, right, back-right, back, back-left, left, front-left
1103
- Elevation options: low-angle, eye-level, elevated, high-angle
1104
- Distance options: close-up, medium, wide
1105
-
1106
- Maps common user terms:
1107
- "from the left" → left
1108
- "looking up at" → low-angle
1109
- "3/4 view" → front-right
1110
- "portrait" → front-right eye-level medium`,
1111
- inputSchema: {
1112
- type: 'object',
1113
- properties: {
1114
- source_image: {
1115
- type: 'string',
1116
- description: 'Path to source image',
1117
- },
1118
- azimuth: {
1119
- type: 'string',
1120
- enum: ['front', 'front-right', 'right', 'back-right', 'back', 'back-left', 'left', 'front-left'],
1121
- description: 'Horizontal camera angle (default: front-right)',
1122
- },
1123
- elevation: {
1124
- type: 'string',
1125
- enum: ['low-angle', 'eye-level', 'elevated', 'high-angle'],
1126
- description: 'Vertical camera angle (default: eye-level)',
1127
- },
1128
- distance: {
1129
- type: 'string',
1130
- enum: ['close-up', 'medium', 'wide'],
1131
- description: 'Camera distance (default: medium)',
1132
- },
1133
- prompt: {
1134
- type: 'string',
1135
- description: 'Subject description (optional, helps preserve identity)',
1136
- },
1137
- lora_strength: {
1138
- type: 'number',
1139
- description: 'LoRA strength 0.1-1.0 (default: 0.9, lower preserves more original appearance)',
1140
- },
1141
- },
1142
- required: ['source_image'],
1143
- },
1144
- },
1145
- ];
1146
-
1147
- // ---------------------------------------------------------------------------
1148
- // Tool handlers
1149
- // ---------------------------------------------------------------------------
1150
-
1151
- async function handleGenerateImage(params) {
1152
- sanitizeString(params.prompt, 'prompt');
1153
- const args = [];
1154
- if (params.quality) args.push('--quality', validateEnum(params.quality, ['fast', 'hq', 'pro'], 'quality'));
1155
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1156
- if (params.width) args.push('-w', String(params.width));
1157
- if (params.height) args.push('-h', String(params.height));
1158
- if (params.count) args.push('-n', String(params.count));
1159
- if (params.seed != null) args.push('-s', String(params.seed));
1160
- if (params.output) args.push('-o', sanitizeString(params.output, 'output'));
1161
- if (params.output_format) args.push('--output-format', validateEnum(params.output_format, ['png', 'jpg'], 'output_format'));
1162
- if (params.loras?.length) {
1163
- params.loras.forEach((l, i) => sanitizeString(l, `loras[${i}]`));
1164
- args.push('--loras', params.loras.join(','));
1165
- }
1166
- if (params.lora_strengths?.length) args.push('--lora-strengths', params.lora_strengths.join(','));
1167
- args.push('--', params.prompt);
1168
-
1169
- return runAndFormat(args, { timeoutMs: 60_000 });
1170
- }
1171
-
1172
- async function handleGenerateVideo(params) {
1173
- sanitizeString(params.prompt, 'prompt');
1174
- const args = ['--video'];
1175
- if (params.workflow) args.push('--workflow', validateEnum(params.workflow, ['t2v', 'i2v', 's2v', 'ia2v', 'a2v', 'v2v', 'animate-move', 'animate-replace'], 'workflow'));
1176
- if (params.quality) args.push('--quality', validateEnum(params.quality, ['fast', 'hq', 'pro'], 'quality'));
1177
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1178
- if (params.width) args.push('-w', String(params.width));
1179
- if (params.height) args.push('-h', String(params.height));
1180
- if (params.fps) args.push('--fps', String(params.fps));
1181
- if (params.duration) args.push('--duration', String(params.duration));
1182
- if (params.frames) args.push('--frames', String(params.frames));
1183
- if (params.target_resolution) args.push('--target-resolution', String(params.target_resolution));
1184
- if (params.ref) args.push('--ref', sanitizeString(params.ref, 'ref'));
1185
- if (params.ref_end) args.push('--ref-end', sanitizeString(params.ref_end, 'ref_end'));
1186
- if (params.ref_audio) args.push('--ref-audio', sanitizeString(params.ref_audio, 'ref_audio'));
1187
- if (params.audio_start != null) args.push('--audio-start', String(params.audio_start));
1188
- if (params.audio_duration != null) args.push('--audio-duration', String(params.audio_duration));
1189
- if (params.reference_audio_identity) args.push('--reference-audio-identity', sanitizeString(params.reference_audio_identity, 'reference_audio_identity'));
1190
- if (params.voice_persona) args.push('--voice-persona', sanitizeString(params.voice_persona, 'voice_persona'));
1191
- if (params.ref_video) args.push('--ref-video', sanitizeString(params.ref_video, 'ref_video'));
1192
- if (params.video_start != null) args.push('--video-start', String(params.video_start));
1193
- if (params.controlnet_name) args.push('--controlnet-name', validateEnum(params.controlnet_name, ['canny', 'pose', 'depth', 'detailer'], 'controlnet_name'));
1194
- if (params.controlnet_strength != null) args.push('--controlnet-strength', String(params.controlnet_strength));
1195
- if (params.sam2_coordinates) args.push('--sam2-coordinates', sanitizeString(params.sam2_coordinates, 'sam2_coordinates'));
1196
- if (params.trim_end_frame) args.push('--trim-end-frame');
1197
- if (params.first_frame_strength != null) args.push('--first-frame-strength', String(params.first_frame_strength));
1198
- if (params.last_frame_strength != null) args.push('--last-frame-strength', String(params.last_frame_strength));
1199
- if (params.seed != null) args.push('-s', String(params.seed));
1200
- if (params.output) args.push('-o', sanitizeString(params.output, 'output'));
1201
- if (params.looping) args.push('--looping');
1202
- args.push('--', params.prompt);
1203
-
1204
- return runAndFormat(args, { timeoutMs: 600_000 });
1205
- }
1206
-
1207
- async function handleAnimatePhoto(params) {
1208
- if (!params.ref) {
1209
- return { content: [{ type: 'text', text: 'Error: animate_photo requires "ref" (reference image).' }], isError: true };
1210
- }
1211
- return handleGenerateVideo({
1212
- ...params,
1213
- workflow: 'i2v',
1214
- });
1215
- }
1216
-
1217
- async function handleSoundToVideo(params) {
1218
- if (!params.ref_audio) {
1219
- return { content: [{ type: 'text', text: 'Error: sound_to_video requires "ref_audio".' }], isError: true };
1220
- }
1221
- return handleGenerateVideo({
1222
- ...params,
1223
- workflow: params.workflow || undefined,
1224
- });
1225
- }
1226
-
1227
- async function handleVideoToVideo(params) {
1228
- if (!params.ref_video) {
1229
- return { content: [{ type: 'text', text: 'Error: video_to_video requires "ref_video".' }], isError: true };
1230
- }
1231
- const seedanceV2v = typeof params.model === 'string' && params.model.toLowerCase().includes('seedance');
1232
- if (!params.controlnet_name && !seedanceV2v) {
1233
- return { content: [{ type: 'text', text: 'Error: video_to_video requires "controlnet_name".' }], isError: true };
1234
- }
1235
- return handleGenerateVideo({
1236
- ...params,
1237
- workflow: 'v2v',
1238
- });
1239
- }
1240
-
1241
- async function handleEditImage(params) {
1242
- sanitizeString(params.prompt, 'prompt');
1243
- const args = [];
1244
- for (const img of params.context_images) {
1245
- args.push('-c', sanitizeString(img, 'context_images'));
1246
- }
1247
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1248
- if (params.width) args.push('-w', String(params.width));
1249
- if (params.height) args.push('-h', String(params.height));
1250
- if (params.output) args.push('-o', sanitizeString(params.output, 'output'));
1251
- args.push('--', params.prompt);
1252
-
1253
- return runAndFormat(args, { timeoutMs: 60_000 });
1254
- }
1255
-
1256
- async function handlePhotobooth(params) {
1257
- sanitizeString(params.prompt, 'prompt');
1258
- sanitizeString(params.reference_face, 'reference_face');
1259
- const args = ['--photobooth', '--ref', params.reference_face];
1260
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1261
- if (params.cn_strength != null) args.push('--cn-strength', String(params.cn_strength));
1262
- if (params.cn_guidance_end != null) args.push('--cn-guidance-end', String(params.cn_guidance_end));
1263
- if (params.width) args.push('-w', String(params.width));
1264
- if (params.height) args.push('-h', String(params.height));
1265
- if (params.count) args.push('-n', String(params.count));
1266
- if (params.output) args.push('-o', sanitizeString(params.output, 'output'));
1267
- args.push('--', params.prompt);
1268
-
1269
- return runAndFormat(args, { timeoutMs: 60_000 });
1270
- }
1271
-
1272
- async function handleCheckBalance() {
1273
- return runAndFormat(['--balance'], { timeoutMs: 30_000 });
1274
- }
1275
-
1276
- async function handleGetVersion() {
1277
- const result = await runSogniAgent(['--version'], { timeoutMs: 5_000 });
1278
- if (result.success === false) return formatError(result);
1279
- return {
1280
- content: [{
1281
- type: 'text',
1282
- text: `mcp-server version: ${SERVER_VERSION}\nsogni-agent version: ${result.version || 'unknown'}`,
1283
- }],
1284
- };
1285
- }
1286
-
1287
- function handleListModels() {
1288
- const text = `${IMAGE_MODEL_TABLE}
1289
-
1290
- Photobooth Model:
1291
- coreml-sogniXLturbo_alpha1_ad — Fast, face transfer (SDXL Turbo, default for --photobooth)
1292
-
1293
- ${VIDEO_MODEL_TABLE}
1294
-
1295
- Defaults:
1296
- Image generation: z_image_turbo_bf16
1297
- Image editing: qwen_image_edit_2511_fp8_lightning
1298
- Photobooth: coreml-sogniXLturbo_alpha1_ad
1299
- Video: auto-selected per workflow (t2v/i2v/s2v/ia2v/a2v/v2v/animate-move/animate-replace)`;
1300
-
1301
- return { content: [{ type: 'text', text }] };
1302
- }
1303
-
1304
- async function handleExtractLastFrame(params) {
1305
- const videoPath = sanitizeString(params.video_path, 'video_path');
1306
- const outputPath = sanitizeString(params.output_path, 'output_path');
1307
- const result = await runSogniAgent(['--extract-last-frame', videoPath, outputPath], { timeoutMs: 30_000 });
1308
- if (result.success === false) return formatError(result);
1309
- return { content: [{ type: 'text', text: `Extracted last frame to: ${result.outputPath || outputPath}` }] };
1310
- }
1311
-
1312
- async function handleConcatVideos(params) {
1313
- const outputPath = sanitizeString(params.output_path, 'output_path');
1314
- if (!params.clips || params.clips.length < 2) {
1315
- return { content: [{ type: 'text', text: 'Error: At least 2 clips are required.' }], isError: true };
1316
- }
1317
- const clips = params.clips.map((c, i) => sanitizeString(c, `clips[${i}]`));
1318
- const args = ['--concat-videos', outputPath, ...clips];
1319
- if (params.audio_path) args.push('--concat-audio', sanitizeString(params.audio_path, 'audio_path'));
1320
- if (params.audio_start != null) args.push('--concat-audio-start', String(params.audio_start));
1321
- const result = await runSogniAgent(args, { timeoutMs: 60_000 });
1322
- if (result.success === false) return formatError(result);
1323
- return { content: [{ type: 'text', text: `Concatenated ${result.clipCount || clips.length} clips to: ${result.outputPath || outputPath}${result.audioPath ? ` with audio ${result.audioPath}` : ''}` }] };
1324
- }
1325
-
1326
- async function handleListMedia(params) {
1327
- const args = ['--list-media'];
1328
- if (params.type) {
1329
- args.push(validateEnum(params.type, ['images', 'audio', 'all'], 'type'));
1330
- }
1331
- const result = await runSogniAgent(args, { timeoutMs: 10_000 });
1332
- if (result.success === false) return formatError(result);
1333
- const files = result.files || [];
1334
- if (files.length === 0) {
1335
- return { content: [{ type: 'text', text: `No ${result.mediaType || 'media'} files found.` }] };
1336
- }
1337
- const lines = files.map(f => `${f.name} (${f.size} bytes, ${f.modified})\n ${f.path}`);
1338
- return { content: [{ type: 'text', text: `Recent ${result.mediaType || 'media'} (${files.length}):\n${lines.join('\n')}` }] };
1339
- }
1340
-
1341
- async function handleRefineResult(params) {
1342
- const lastRenderPath = join(homedir(), '.config', 'sogni', 'last-render.json');
1343
- if (!existsSync(lastRenderPath)) {
1344
- return {
1345
- content: [{ type: 'text', text: 'Error: No previous render found. Generate something first, then use refine_result to tweak it.' }],
1346
- isError: true,
1347
- };
1348
- }
1349
-
1350
- let lastRender;
1351
- try {
1352
- lastRender = JSON.parse(readFileSync(lastRenderPath, 'utf8'));
1353
- } catch {
1354
- return {
1355
- content: [{ type: 'text', text: 'Error: Could not read last render metadata.' }],
1356
- isError: true,
1357
- };
1358
- }
1359
-
1360
- const isVideo = lastRender.type === 'video';
1361
-
1362
- if (isVideo) {
1363
- // Re-run as video
1364
- const prompt = params.prompt ? sanitizeString(params.prompt, 'prompt') : lastRender.prompt;
1365
- const args = ['--video'];
1366
- if (lastRender.workflow) args.push('--workflow', lastRender.workflow);
1367
- if (params.quality) args.push('--quality', validateEnum(params.quality, ['fast', 'hq', 'pro'], 'quality'));
1368
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1369
- else if (lastRender.model) args.push('-m', lastRender.model);
1370
- if (params.width) args.push('-w', String(params.width));
1371
- else if (lastRender.width) args.push('-w', String(lastRender.width));
1372
- if (params.height) args.push('-h', String(params.height));
1373
- else if (lastRender.height) args.push('-h', String(lastRender.height));
1374
- if (params.seed != null) args.push('-s', String(params.seed));
1375
- else if (lastRender.seed != null) args.push('-s', String(lastRender.seed));
1376
- if (lastRender.fps) args.push('--fps', String(lastRender.fps));
1377
- if (lastRender.frames) args.push('--frames', String(lastRender.frames));
1378
- else if (lastRender.duration) args.push('--duration', String(lastRender.duration));
1379
- if (lastRender.targetResolution) args.push('--target-resolution', String(lastRender.targetResolution));
1380
- if (lastRender.refImage) args.push('--ref', lastRender.refImage);
1381
- if (lastRender.refImageEnd) args.push('--ref-end', lastRender.refImageEnd);
1382
- if (lastRender.refAudio) {
1383
- args.push('--ref-audio', lastRender.refAudio);
1384
- if (lastRender.audioStart != null) args.push('--audio-start', String(lastRender.audioStart));
1385
- if (lastRender.audioDuration != null) args.push('--audio-duration', String(lastRender.audioDuration));
1386
- }
1387
- if (lastRender.referenceAudioIdentity) args.push('--reference-audio-identity', lastRender.referenceAudioIdentity);
1388
- if (lastRender.voicePersonaName) args.push('--voice-persona', lastRender.voicePersonaName);
1389
- if (lastRender.refVideo) {
1390
- args.push('--ref-video', lastRender.refVideo);
1391
- if (lastRender.videoStart != null) args.push('--video-start', String(lastRender.videoStart));
1392
- }
1393
- if (lastRender.autoResizeVideoAssets === true) args.push('--auto-resize-assets');
1394
- else if (lastRender.autoResizeVideoAssets === false) args.push('--no-auto-resize-assets');
1395
- if (lastRender.controlNet?.name) args.push('--controlnet-name', lastRender.controlNet.name);
1396
- if (lastRender.controlNet?.strength != null) args.push('--controlnet-strength', String(lastRender.controlNet.strength));
1397
- if (lastRender.sam2Coordinates) {
1398
- const coords = Array.isArray(lastRender.sam2Coordinates)
1399
- ? lastRender.sam2Coordinates.map((p) => `${p.x},${p.y}`).join(';')
1400
- : String(lastRender.sam2Coordinates);
1401
- args.push('--sam2-coordinates', coords);
1402
- }
1403
- if (lastRender.trimEndFrame) args.push('--trim-end-frame');
1404
- if (lastRender.firstFrameStrength != null) args.push('--first-frame-strength', String(lastRender.firstFrameStrength));
1405
- if (lastRender.lastFrameStrength != null) args.push('--last-frame-strength', String(lastRender.lastFrameStrength));
1406
- args.push('--', prompt);
1407
- return runAndFormat(args, { timeoutMs: 600_000 });
1408
- } else {
1409
- // Re-run as image
1410
- const prompt = params.prompt ? sanitizeString(params.prompt, 'prompt') : lastRender.prompt;
1411
- const args = [];
1412
- if (params.quality) args.push('--quality', validateEnum(params.quality, ['fast', 'hq', 'pro'], 'quality'));
1413
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1414
- else if (lastRender.model) args.push('-m', lastRender.model);
1415
- if (params.width) args.push('-w', String(params.width));
1416
- else if (lastRender.width) args.push('-w', String(lastRender.width));
1417
- if (params.height) args.push('-h', String(params.height));
1418
- else if (lastRender.height) args.push('-h', String(lastRender.height));
1419
- if (params.count) args.push('-n', String(params.count));
1420
- else if (lastRender.count) args.push('-n', String(lastRender.count));
1421
- if (params.seed != null) args.push('-s', String(params.seed));
1422
- else if (lastRender.seed != null) args.push('-s', String(lastRender.seed));
1423
- if (lastRender.contextImages?.length > 0) {
1424
- for (const img of lastRender.contextImages) {
1425
- args.push('-c', img);
1426
- }
1427
- }
1428
- if (lastRender.photobooth && lastRender.refImage) {
1429
- args.push('--photobooth', '--ref', lastRender.refImage);
1430
- }
1431
- args.push('--', prompt);
1432
- return runAndFormat(args, { timeoutMs: 60_000 });
1433
- }
1434
- }
1435
-
1436
- async function handleEstimateCost(params) {
1437
- const isVideo = params.type === 'video';
1438
-
1439
- if (isVideo) {
1440
- if (!params.steps) {
1441
- return {
1442
- content: [{ type: 'text', text: 'Error: Video cost estimation requires the "steps" parameter.' }],
1443
- isError: true,
1444
- };
1445
- }
1446
- const args = ['--video', '--estimate-video-cost'];
1447
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1448
- if (params.width) args.push('-w', String(params.width));
1449
- if (params.height) args.push('-h', String(params.height));
1450
- if (params.fps) args.push('--fps', String(params.fps));
1451
- if (params.duration) args.push('--duration', String(params.duration));
1452
- if (params.count) args.push('-n', String(params.count));
1453
- args.push('--steps', String(params.steps));
1454
- // Need a dummy prompt for the CLI
1455
- args.push('--', 'cost-estimate');
1456
- return runAndFormat(args, { timeoutMs: 30_000 });
1457
- } else {
1458
- // Image cost: check balance and provide guidance
1459
- const balanceResult = await runSogniAgent(['--balance'], { timeoutMs: 30_000 });
1460
- if (balanceResult.success === false) return formatError(balanceResult);
1461
-
1462
- const model = params.model || 'z_image_turbo_bf16';
1463
- const w = params.width || 512;
1464
- const h = params.height || 512;
1465
- const count = params.count || 1;
1466
-
1467
- // Rough cost heuristic based on model and pixel count
1468
- const pixels = w * h;
1469
- const basePixels = 512 * 512;
1470
- const pixelMultiplier = pixels / basePixels;
1471
- let baseCost;
1472
- if (model.includes('flux2')) baseCost = 5.0;
1473
- else if (model.includes('flux1')) baseCost = 0.5;
1474
- else if (model.includes('chroma')) baseCost = 2.0;
1475
- else if (model.includes('qwen')) baseCost = 1.5;
1476
- else baseCost = 0.8; // z_image_turbo default
1477
-
1478
- const estimatedCost = baseCost * pixelMultiplier * count;
1479
-
1480
- const text = [
1481
- `Estimated image cost:`,
1482
- ` Model: ${model}`,
1483
- ` Size: ${w}x${h} (${count} image${count > 1 ? 's' : ''})`,
1484
- ` Estimated SPARK: ~${estimatedCost.toFixed(2)}`,
1485
- ``,
1486
- `Current balance:`,
1487
- ` SPARK: ${balanceResult.spark ?? 'N/A'}`,
1488
- ` SOGNI: ${balanceResult.sogni ?? 'N/A'}`,
1489
- ``,
1490
- `Note: Image cost estimates are approximate. Video estimates are precise (use type="video" with steps).`
1491
- ].join('\n');
1492
-
1493
- return { content: [{ type: 'text', text }] };
1494
- }
1495
- }
1496
-
1497
- async function handleManageMemory(params) {
1498
- const action = validateEnum(params.action, ['read', 'write', 'delete'], 'action');
1499
- if (action === 'read') {
1500
- const args = ['--json'];
1501
- if (params.key) {
1502
- args.push('--memory-get', sanitizeString(params.key, 'key'));
1503
- } else {
1504
- args.push('--memory-list');
1505
- }
1506
- return runAndFormat(args, { timeoutMs: 5_000, requireCredentials: false });
1507
- } else if (action === 'write') {
1508
- if (!params.key || !params.value) {
1509
- return { content: [{ type: 'text', text: 'Error: "key" and "value" are required for write.' }], isError: true };
1510
- }
1511
- const args = ['--json', '--memory-set', sanitizeString(params.key, 'key'), sanitizeString(params.value, 'value')];
1512
- if (params.category) args.push('--memory-category', validateEnum(params.category, ['preference', 'fact', 'context'], 'category'));
1513
- return runAndFormat(args, { timeoutMs: 5_000, requireCredentials: false });
1514
- } else {
1515
- if (!params.key) {
1516
- return { content: [{ type: 'text', text: 'Error: "key" is required for delete.' }], isError: true };
1517
- }
1518
- const args = ['--json', '--memory-remove', sanitizeString(params.key, 'key')];
1519
- return runAndFormat(args, { timeoutMs: 5_000, requireCredentials: false });
1520
- }
1521
- }
1522
-
1523
- async function handleManagePersonality(params) {
1524
- const action = validateEnum(params.action, ['get', 'set', 'clear'], 'action');
1525
- if (action === 'get') {
1526
- return runAndFormat(['--json', '--personality-get'], { timeoutMs: 5_000, requireCredentials: false });
1527
- } else if (action === 'set') {
1528
- if (!params.text) {
1529
- return { content: [{ type: 'text', text: 'Error: "text" is required for set.' }], isError: true };
1530
- }
1531
- return runAndFormat(['--json', '--personality-set', sanitizeString(params.text, 'text')], { timeoutMs: 5_000, requireCredentials: false });
1532
- } else {
1533
- return runAndFormat(['--json', '--personality-clear'], { timeoutMs: 5_000, requireCredentials: false });
1534
- }
1535
- }
1536
-
1537
- async function handleManagePersonas(params) {
1538
- const action = validateEnum(params.action, ['list', 'add', 'remove', 'resolve'], 'action');
1539
- if (action === 'list') {
1540
- return runAndFormat(['--json', '--persona-list'], { timeoutMs: 5_000, requireCredentials: false });
1541
- } else if (action === 'add') {
1542
- if (!params.name || !params.photo_path) {
1543
- return { content: [{ type: 'text', text: 'Error: "name" and "photo_path" are required for add.' }], isError: true };
1544
- }
1545
- const args = ['--json', '--persona-add', sanitizeString(params.name, 'name'), '--ref', sanitizeString(params.photo_path, 'photo_path')];
1546
- if (params.relationship) args.push('--relationship', validateEnum(params.relationship, ['self', 'partner', 'child', 'friend', 'pet'], 'relationship'));
1547
- if (params.description) args.push('--description', sanitizeString(params.description, 'description'));
1548
- if (params.tags?.length) args.push('--tags', params.tags.map((t, i) => sanitizeString(t, `tags[${i}]`)).join(','));
1549
- if (params.voice) args.push('--voice', sanitizeString(params.voice, 'voice'));
1550
- if (params.voice_clip_path) args.push('--voice-clip', sanitizeString(params.voice_clip_path, 'voice_clip_path'));
1551
- return runAndFormat(args, { timeoutMs: 10_000, requireCredentials: false });
1552
- } else if (action === 'remove') {
1553
- if (!params.name) {
1554
- return { content: [{ type: 'text', text: 'Error: "name" is required for remove.' }], isError: true };
1555
- }
1556
- return runAndFormat(['--json', '--persona-remove', sanitizeString(params.name, 'name')], { timeoutMs: 5_000, requireCredentials: false });
1557
- } else {
1558
- if (!params.name) {
1559
- return { content: [{ type: 'text', text: 'Error: "name" is required for resolve.' }], isError: true };
1560
- }
1561
- return runAndFormat(['--json', '--persona-resolve', sanitizeString(params.name, 'name')], { timeoutMs: 5_000, requireCredentials: false });
1562
- }
1563
- }
1564
-
1565
- async function handleApplyStyle(params) {
1566
- sanitizeString(params.prompt, 'prompt');
1567
- sanitizeString(params.source_image, 'source_image');
1568
- const args = ['-c', params.source_image];
1569
- if (params.model) args.push('-m', sanitizeString(params.model, 'model'));
1570
- else args.push('-m', 'qwen_image_edit_2511_fp8_lightning');
1571
- if (params.width) args.push('-w', String(params.width));
1572
- if (params.height) args.push('-h', String(params.height));
1573
- args.push('--', `Apply style: ${params.prompt}`);
1574
- return runAndFormat(args, { timeoutMs: 60_000 });
1575
- }
1576
-
1577
- async function handleChangeAngle(params) {
1578
- sanitizeString(params.source_image, 'source_image');
1579
- const args = ['--multi-angle', '-c', params.source_image];
1580
- if (params.azimuth) args.push('--azimuth', sanitizeString(params.azimuth, 'azimuth'));
1581
- if (params.elevation) args.push('--elevation', sanitizeString(params.elevation, 'elevation'));
1582
- if (params.distance) args.push('--distance', sanitizeString(params.distance, 'distance'));
1583
- if (params.lora_strength != null) args.push('--angle-strength', String(params.lora_strength));
1584
- if (params.prompt) args.push('--angle-description', sanitizeString(params.prompt, 'prompt'));
1585
- args.push('--', params.prompt || 'same subject from a different angle');
1586
- return runAndFormat(args, { timeoutMs: 60_000 });
1587
- }
1588
-
1589
- // ---------------------------------------------------------------------------
1590
- // Server setup
1591
- // ---------------------------------------------------------------------------
1592
-
1593
- const server = new Server(
1594
- { name: 'sogni', version: SERVER_VERSION },
1595
- { capabilities: { tools: {} } },
1596
- );
1597
-
1598
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1599
-
1600
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1601
- const { name, arguments: params } = request.params;
1602
- try {
1603
- switch (name) {
1604
- case 'generate_image':
1605
- return await handleGenerateImage(params);
1606
- case 'generate_video':
1607
- return await handleGenerateVideo(params);
1608
- case 'animate_photo':
1609
- return await handleAnimatePhoto(params);
1610
- case 'sound_to_video':
1611
- return await handleSoundToVideo(params);
1612
- case 'video_to_video':
1613
- return await handleVideoToVideo(params);
1614
- case 'edit_image':
1615
- return await handleEditImage(params);
1616
- case 'photobooth':
1617
- return await handlePhotobooth(params);
1618
- case 'check_balance':
1619
- return await handleCheckBalance();
1620
- case 'list_models':
1621
- return handleListModels();
1622
- case 'get_version':
1623
- return await handleGetVersion();
1624
- case 'extract_last_frame':
1625
- return await handleExtractLastFrame(params);
1626
- case 'concat_videos':
1627
- return await handleConcatVideos(params);
1628
- case 'stitch_video':
1629
- return await handleConcatVideos(params);
1630
- case 'list_media':
1631
- return await handleListMedia(params);
1632
- case 'refine_result':
1633
- return await handleRefineResult(params);
1634
- case 'estimate_cost':
1635
- return await handleEstimateCost(params);
1636
- case 'manage_memory':
1637
- return await handleManageMemory(params);
1638
- case 'manage_personality':
1639
- return await handleManagePersonality(params);
1640
- case 'manage_personas':
1641
- return await handleManagePersonas(params);
1642
- case 'apply_style':
1643
- return await handleApplyStyle(params);
1644
- case 'change_angle':
1645
- return await handleChangeAngle(params);
1646
- default:
1647
- return {
1648
- content: [{ type: 'text', text: `Unknown tool: ${name}` }],
1649
- isError: true,
1650
- };
1651
- }
1652
- } catch (err) {
1653
- return {
1654
- content: [{ type: 'text', text: `Error: ${err.message}` }],
1655
- isError: true,
1656
- };
1657
- }
1658
- });
1659
-
1660
- // ---------------------------------------------------------------------------
1661
- // Start
1662
- // ---------------------------------------------------------------------------
1663
-
1664
- const transport = new StdioServerTransport();
1665
- await server.connect(transport);