@olo-studio/studio-mcp 0.0.0-stage → 1.0.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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Headless Chrome / Playwright deterministic video renderer and GLB inspector.
3
+ * Calls `window.OloStudioBridge.exportSceneBuffer` against local static `public/` or `https://www.olomni.com/studio`.
4
+ */
5
+ import fs from 'node:fs'
6
+ import fsp from 'node:fs/promises'
7
+ import http from 'node:http'
8
+ import path from 'node:path'
9
+ import { createRequire } from 'node:module'
10
+ import JSZip from 'jszip'
11
+ import { compileSceneSpec, findPublicStudioDir, generateSrt, parseBatchInput } from './compiler.js'
12
+
13
+ export const VALID_FORMATS = ['mp4', 'webm', 'webm_alpha', 'alpha', 'chroma']
14
+
15
+ export function validateRenderFormat(rawFormat) {
16
+ const fmt = String(rawFormat || 'mp4').trim().toLowerCase()
17
+ if (!VALID_FORMATS.includes(fmt)) {
18
+ throw new Error(`Unsupported render format "${fmt}". Supported formats: ${VALID_FORMATS.join(', ')}`)
19
+ }
20
+ return fmt
21
+ }
22
+
23
+ const MIME_TYPES = {
24
+ '.html': 'text/html; charset=utf-8',
25
+ '.js': 'application/javascript; charset=utf-8',
26
+ '.mjs': 'application/javascript; charset=utf-8',
27
+ '.css': 'text/css; charset=utf-8',
28
+ '.json': 'application/json; charset=utf-8',
29
+ '.png': 'image/png',
30
+ '.jpg': 'image/jpeg',
31
+ '.jpeg': 'image/jpeg',
32
+ '.webp': 'image/webp',
33
+ '.svg': 'image/svg+xml',
34
+ '.glb': 'model/gltf-binary',
35
+ '.gltf': 'model/gltf+json',
36
+ '.woff2': 'font/woff2',
37
+ '.wasm': 'application/wasm',
38
+ '.mp3': 'audio/mpeg',
39
+ '.wav': 'audio/wav',
40
+ }
41
+
42
+ export function findChromeExecutable(overridePath) {
43
+ const candidates = [
44
+ overridePath,
45
+ process.env.OLO_CHROME_PATH,
46
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
47
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
48
+ '/usr/bin/google-chrome',
49
+ '/usr/bin/chromium',
50
+ '/usr/bin/chromium-browser',
51
+ ].filter(Boolean)
52
+
53
+ for (const candidate of candidates) {
54
+ if (fs.existsSync(candidate)) {
55
+ return candidate
56
+ }
57
+ }
58
+ return null
59
+ }
60
+
61
+ async function loadPlaywright() {
62
+ try {
63
+ return await import('playwright')
64
+ } catch {
65
+ // Try resolving from cwd (e.g., monorepo root devDependencies)
66
+ try {
67
+ const req = createRequire(path.join(process.cwd(), 'package.json'))
68
+ return req('playwright')
69
+ } catch {
70
+ try {
71
+ return await import('playwright-core')
72
+ } catch {
73
+ throw new Error(
74
+ 'Playwright is required for headless video rendering. Install `playwright` in your workspace or set OLO_STUDIO_URL.',
75
+ )
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ export function startStaticServer(rootDir) {
82
+ return new Promise((resolve, reject) => {
83
+ const resolvedRoot = path.resolve(rootDir)
84
+ const server = http.createServer((req, res) => {
85
+ const urlPath = (req.url || '/').split('?')[0]
86
+ let relPath = decodeURIComponent(urlPath).replace(/^\/+/, '')
87
+ if (!relPath || relPath.endsWith('/')) relPath += 'index.html'
88
+ const absPath = path.resolve(resolvedRoot, relPath)
89
+
90
+ if (!absPath.startsWith(resolvedRoot)) {
91
+ res.writeHead(403)
92
+ res.end('Forbidden')
93
+ return
94
+ }
95
+
96
+ fs.stat(absPath, (err, stat) => {
97
+ if (err || !stat.isFile()) {
98
+ // Fallback to /studio/index.html if requesting /studio
99
+ const indexFallback = path.join(absPath, 'index.html')
100
+ if (fs.existsSync(indexFallback)) {
101
+ const data = fs.readFileSync(indexFallback)
102
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
103
+ res.end(data)
104
+ return
105
+ }
106
+ res.writeHead(404)
107
+ res.end('Not Found')
108
+ return
109
+ }
110
+
111
+ const ext = path.extname(absPath).toLowerCase()
112
+ res.writeHead(200, {
113
+ 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
114
+ 'Cache-Control': 'no-cache',
115
+ })
116
+ fs.createReadStream(absPath).pipe(res)
117
+ })
118
+ })
119
+
120
+ server.on('error', reject)
121
+ server.listen(0, '127.0.0.1', () => {
122
+ const addr = server.address()
123
+ resolve({
124
+ server,
125
+ port: addr.port,
126
+ baseUrl: `http://127.0.0.1:${addr.port}`,
127
+ close: () => new Promise((resClose) => server.close(() => resClose())),
128
+ })
129
+ })
130
+ })
131
+ }
132
+
133
+ async function openStudioSession(opts = {}) {
134
+ const { chromium } = await loadPlaywright()
135
+ const chromePath = findChromeExecutable(opts.chromePath)
136
+
137
+ let staticSrv = null
138
+ let targetUrl = opts.studioUrl || process.env.OLO_STUDIO_URL || ''
139
+
140
+ if (!targetUrl) {
141
+ const publicDir = findPublicStudioDir(opts.publicDir)
142
+ if (publicDir) {
143
+ staticSrv = await startStaticServer(publicDir)
144
+ targetUrl = `${staticSrv.baseUrl}/studio/index.html`
145
+ } else {
146
+ targetUrl = 'https://www.olomni.com/studio'
147
+ }
148
+ }
149
+
150
+ const browser = await chromium.launch({
151
+ headless: true,
152
+ ...(chromePath ? { executablePath: chromePath } : {}),
153
+ args: [
154
+ '--use-gl=angle',
155
+ '--use-angle=swiftshader',
156
+ '--enable-webgl',
157
+ '--ignore-gpu-blocklist',
158
+ '--autoplay-policy=no-user-gesture-required',
159
+ '--no-sandbox',
160
+ ],
161
+ })
162
+
163
+ try {
164
+ const context = await browser.newContext({
165
+ viewport: { width: 1280, height: 900 },
166
+ })
167
+ const page = await context.newPage()
168
+
169
+ await page.goto(targetUrl, {
170
+ waitUntil: 'domcontentloaded',
171
+ timeout: opts.timeoutMs || 30000,
172
+ })
173
+
174
+ await page.waitForFunction(
175
+ () => Boolean(window.OloStudioBridge && window.OloStudioBridge.ready === true),
176
+ null,
177
+ { timeout: opts.timeoutMs || 25000 },
178
+ )
179
+
180
+ return { browser, context, page, staticSrv, targetUrl, chromePath }
181
+ } catch (err) {
182
+ await browser.close().catch(() => {})
183
+ if (staticSrv) await staticSrv.close().catch(() => {})
184
+ throw err
185
+ }
186
+ }
187
+
188
+ async function renderOnPage(page, opts) {
189
+ const compiled = compileSceneSpec(opts.sceneSpec || opts)
190
+ const format = validateRenderFormat(opts.format)
191
+
192
+ if (opts.glbPath) {
193
+ const absGlb = path.resolve(opts.glbPath)
194
+ const buf = await fsp.readFile(absGlb)
195
+ const b64 = buf.toString('base64')
196
+ await page.evaluate(
197
+ async ({ base64, fileName }) => {
198
+ const bin = atob(base64)
199
+ const bytes = new Uint8Array(bin.length)
200
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
201
+ await window.OloStudioBridge.loadGlbFromArrayBuffer(bytes.buffer, fileName)
202
+ },
203
+ { base64: b64, fileName: path.basename(absGlb) },
204
+ )
205
+ } else if (opts.glbUrl) {
206
+ await page.evaluate(async (url) => {
207
+ await window.OloStudioBridge.loadGlbFromUrl(url)
208
+ }, opts.glbUrl)
209
+ }
210
+
211
+ const exportResult = await page.evaluate(
212
+ async ({ scene, fmt }) => {
213
+ const res = await window.OloStudioBridge.exportSceneBuffer(scene, fmt)
214
+ const bytes = Array.from(new Uint8Array(res.arrayBuffer))
215
+ return {
216
+ bytes,
217
+ mimeType: res.mimeType,
218
+ ext: res.ext,
219
+ durationSec: res.durationSec,
220
+ watermarked: res.watermarked,
221
+ }
222
+ },
223
+ { scene: compiled, fmt: format },
224
+ )
225
+
226
+ const buffer = Buffer.from(exportResult.bytes)
227
+ const outPath = path.resolve(opts.outputPath || `olo-render-${compiled.sceneHash}.${exportResult.ext || 'mp4'}`)
228
+ await fsp.mkdir(path.dirname(outPath), { recursive: true })
229
+ await fsp.writeFile(outPath, buffer)
230
+
231
+ let srtPath = null
232
+ if (opts.srt !== false) {
233
+ const srtContent = generateSrt(compiled)
234
+ if (srtContent) {
235
+ const outputBase = outPath.slice(0, outPath.length - path.extname(outPath).length)
236
+ srtPath = `${outputBase}.srt`
237
+ await fsp.writeFile(srtPath, srtContent, 'utf8')
238
+ }
239
+ }
240
+
241
+ return {
242
+ ok: true,
243
+ outputPath: outPath,
244
+ srtPath,
245
+ bytes: buffer.byteLength,
246
+ mimeType: exportResult.mimeType,
247
+ ext: exportResult.ext,
248
+ durationSec: exportResult.durationSec,
249
+ watermarked: exportResult.watermarked,
250
+ sceneHash: compiled.sceneHash,
251
+ title: compiled.title,
252
+ }
253
+ }
254
+
255
+ export async function renderVideoWithBrowser(opts = {}) {
256
+ validateRenderFormat(opts.format)
257
+ const session = await openStudioSession(opts)
258
+ try {
259
+ const res = await renderOnPage(session.page, opts)
260
+ return {
261
+ ...res,
262
+ studioUrl: session.targetUrl,
263
+ chromePath: session.chromePath || 'playwright-chromium',
264
+ }
265
+ } finally {
266
+ await session.browser.close().catch(() => {})
267
+ if (session.staticSrv) await session.staticSrv.close().catch(() => {})
268
+ }
269
+ }
270
+
271
+ export async function renderBatchWithBrowser(opts = {}) {
272
+ const format = validateRenderFormat(opts.format)
273
+ let variants = Array.isArray(opts.variants) ? opts.variants : []
274
+
275
+ if (variants.length === 0) {
276
+ let csvContent = opts.csv
277
+ if (!csvContent && opts.csvPath) {
278
+ csvContent = await fsp.readFile(path.resolve(opts.csvPath), 'utf8')
279
+ }
280
+ variants = parseBatchInput({ specs: opts.specs, csv: csvContent })
281
+ }
282
+
283
+ if (variants.length === 0) {
284
+ throw new Error('variants array must be non-empty')
285
+ }
286
+
287
+ const outputDir = path.resolve(opts.outputDir || './olo-batch-renders')
288
+ const ext = format.includes('webm') || format === 'alpha' ? 'webm' : 'mp4'
289
+
290
+ const session = await openStudioSession(opts)
291
+ const results = []
292
+ try {
293
+ for (const variant of variants) {
294
+ const outPath = path.join(outputDir, `${variant.outputName}.${ext}`)
295
+ const rendered = await renderOnPage(session.page, {
296
+ sceneSpec: variant.sceneSpec,
297
+ format,
298
+ srt: opts.srt,
299
+ outputPath: outPath,
300
+ glbUrl: variant.glbUrl || opts.glbUrl,
301
+ glbPath: variant.glbPath || opts.glbPath,
302
+ })
303
+ results.push({
304
+ index: variant.index,
305
+ outputName: variant.outputName,
306
+ ...rendered,
307
+ })
308
+ }
309
+
310
+ await fsp.mkdir(outputDir, { recursive: true })
311
+ const manifestPath = path.join(outputDir, 'manifest.json')
312
+ const manifest = {
313
+ ok: true,
314
+ format,
315
+ count: results.length,
316
+ studioUrl: session.targetUrl,
317
+ generatedAt: new Date().toISOString(),
318
+ results,
319
+ }
320
+ await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
321
+
322
+ let zipPath = null
323
+ if (opts.zip !== false) {
324
+ const zip = new JSZip()
325
+ zip.file('manifest.json', await fsp.readFile(manifestPath))
326
+ for (const item of results) {
327
+ if (item.outputPath && fs.existsSync(item.outputPath)) {
328
+ zip.file(path.basename(item.outputPath), await fsp.readFile(item.outputPath))
329
+ }
330
+ if (item.srtPath && fs.existsSync(item.srtPath)) {
331
+ zip.file(path.basename(item.srtPath), await fsp.readFile(item.srtPath))
332
+ }
333
+ }
334
+ zipPath = path.join(outputDir, 'batch.zip')
335
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
336
+ await fsp.writeFile(zipPath, zipBuffer)
337
+ }
338
+
339
+ return {
340
+ ok: true,
341
+ count: results.length,
342
+ outputDir,
343
+ manifestPath,
344
+ zipPath,
345
+ studioUrl: session.targetUrl,
346
+ results,
347
+ }
348
+ } finally {
349
+ await session.browser.close().catch(() => {})
350
+ if (session.staticSrv) await session.staticSrv.close().catch(() => {})
351
+ }
352
+ }
353
+
354
+ export async function inspectGlb(source) {
355
+ if (!source || typeof source !== 'string') {
356
+ throw new Error('GLB file path or https:// URL is required.')
357
+ }
358
+
359
+ let buffer
360
+ if (/^https?:\/\//i.test(source)) {
361
+ const res = await fetch(source, { signal: AbortSignal.timeout(15000) })
362
+ if (!res.ok) throw new Error(`Failed to fetch GLB (${res.status})`)
363
+ buffer = Buffer.from(await res.arrayBuffer())
364
+ } else {
365
+ buffer = await fsp.readFile(path.resolve(source))
366
+ }
367
+
368
+ if (buffer.byteLength < 20) {
369
+ throw new Error('File too small to be a valid GLB binary.')
370
+ }
371
+
372
+ const magic = buffer.readUInt32LE(0)
373
+ if (magic !== 0x46546c67) {
374
+ throw new Error(`Invalid GLB magic header: 0x${magic.toString(16)}`)
375
+ }
376
+
377
+ const version = buffer.readUInt32LE(4)
378
+ const totalLength = buffer.readUInt32LE(8)
379
+ const chunkLength = buffer.readUInt32LE(12)
380
+ const chunkType = buffer.readUInt32LE(16)
381
+
382
+ if (chunkType !== 0x4e4f534a) {
383
+ throw new Error('First GLB chunk is not JSON.')
384
+ }
385
+
386
+ const jsonStr = buffer.subarray(20, 20 + chunkLength).toString('utf8').replace(/\0+$/, '')
387
+ const gltf = JSON.parse(jsonStr)
388
+
389
+ return {
390
+ ok: true,
391
+ source,
392
+ version,
393
+ byteLength: totalLength,
394
+ generator: gltf.asset?.generator || null,
395
+ scenes: Array.isArray(gltf.scenes) ? gltf.scenes.length : 0,
396
+ nodes: Array.isArray(gltf.nodes) ? gltf.nodes.length : 0,
397
+ meshes: Array.isArray(gltf.meshes) ? gltf.meshes.map((m) => m.name || 'unnamed') : [],
398
+ animations: Array.isArray(gltf.animations) ? gltf.animations.map((a) => a.name || 'unnamed') : [],
399
+ materials: Array.isArray(gltf.materials) ? gltf.materials.map((m) => m.name || 'unnamed') : [],
400
+ }
401
+ }
package/src/server.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @olomni/studio-mcp — Local-first MCP Server (JSON-RPC 2.0 over stdio)
3
+ * Registers the 7 canonical Olomni Studio tools via @modelcontextprotocol/sdk.
4
+ */
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
+ import { z } from 'zod'
8
+ import { getEntitlements, generateScript, manageVoiceClones } from './api-client.js'
9
+ import {
10
+ compileSceneSpec,
11
+ buildPublishEmbed,
12
+ VALID_STATES,
13
+ VALID_TINTS,
14
+ VALID_WEARS,
15
+ VALID_FINISHES,
16
+ VALID_SCENARIOS,
17
+ STATE_ALIASES,
18
+ TINT_ALIASES,
19
+ WEAR_ALIASES,
20
+ FINISH_ALIASES,
21
+ SCENARIO_ALIASES,
22
+ } from './compiler.js'
23
+ import { VALID_FORMATS, renderVideoWithBrowser, renderBatchWithBrowser } from './renderer.js'
24
+
25
+ function enumWithAliases(canonical, aliases, label) {
26
+ return z
27
+ .enum([...canonical, ...Object.keys(aliases)])
28
+ .optional()
29
+ .describe(`${label} (canonical: ${canonical.join(', ')})`)
30
+ }
31
+
32
+ function jsonToolResult(payload, isError = false) {
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: JSON.stringify(payload, null, 2),
38
+ },
39
+ ],
40
+ ...(isError ? { isError: true } : {}),
41
+ }
42
+ }
43
+
44
+ export function createStudioMcpServer(serverOpts = {}) {
45
+ const server = new McpServer({
46
+ name: '@olomni/studio-mcp',
47
+ version: '1.0.0',
48
+ })
49
+
50
+ // 1. olo_get_entitlements
51
+ server.tool(
52
+ 'olo_get_entitlements',
53
+ 'Inspect Olomni Studio account tier, capabilities (maxDuration, allowGLB, allowBatch, allowAlphaOrChroma), and upgrade URLs using OLO_STUDIO_API_KEY (or free-tier fallback).',
54
+ {
55
+ apiKey: z.string().optional().describe('Optional olo_pat_... token (defaults to OLO_STUDIO_API_KEY env var).'),
56
+ apiBase: z.string().optional().describe('Optional API base URL (defaults to https://www.olomni.com).'),
57
+ },
58
+ async (args) => {
59
+ const result = await getEntitlements({
60
+ apiKey: args.apiKey || serverOpts.apiKey,
61
+ apiBase: args.apiBase || serverOpts.apiBase,
62
+ })
63
+ return jsonToolResult(result)
64
+ },
65
+ )
66
+
67
+ // 2. olo_generate_script
68
+ server.tool(
69
+ 'olo_generate_script',
70
+ 'Generate a 9:16 video script (Olo: ... / Yo: ...) in product, chisme, or changelog mode via studio-chisme-ai with deterministic offline fallback.',
71
+ {
72
+ mode: z.enum(['product', 'chisme', 'changelog']).default('product').describe('Script style mode.'),
73
+ topic: z.string().min(1).describe('Product, feature, changelog, or story topic.'),
74
+ language: z.string().default('es').describe('Output language code (e.g. es, en, pt).'),
75
+ offlineOnly: z.boolean().optional().describe('Skip network call and use deterministic offline generator.'),
76
+ apiKey: z.string().optional(),
77
+ apiBase: z.string().optional(),
78
+ },
79
+ async (args) => {
80
+ const result = await generateScript({
81
+ mode: args.mode,
82
+ topic: args.topic,
83
+ language: args.language,
84
+ offlineOnly: args.offlineOnly,
85
+ apiKey: args.apiKey || serverOpts.apiKey,
86
+ apiBase: args.apiBase || serverOpts.apiBase,
87
+ })
88
+ return jsonToolResult(result)
89
+ },
90
+ )
91
+
92
+ // 3. olo_compile_scene
93
+ server.tool(
94
+ 'olo_compile_scene',
95
+ 'Compile and validate a deterministic olomni.studio.scene.v1 SceneSpec JSON from script text or structured beats without launching a browser.',
96
+ {
97
+ title: z.string().optional().describe('Show title (max 60 chars).'),
98
+ category: z.string().optional().describe('Category badge (e.g. Show 9:16, Product Demo).'),
99
+ script: z.string().optional().describe('Line-delimited script (e.g. "Olo: Hola\\nYo: Qué tal").'),
100
+ wear: enumWithAliases(VALID_WEARS, WEAR_ALIASES, 'Mascot wear accessory'),
101
+ tint: enumWithAliases(VALID_TINTS, TINT_ALIASES, 'Mascot body tint'),
102
+ finish: enumWithAliases(VALID_FINISHES, FINISH_ALIASES, 'Collector PBR finish'),
103
+ scenario: enumWithAliases(VALID_SCENARIOS, SCENARIO_ALIASES, '3D background scenario'),
104
+ state: enumWithAliases(VALID_STATES, STATE_ALIASES, 'Default 3D character state'),
105
+ stateOverride: enumWithAliases(VALID_STATES, STATE_ALIASES, 'Override 3D character state'),
106
+ emotion: z.string().optional().describe('Legacy alias for stateOverride.'),
107
+ vibe: z.string().optional(),
108
+ cameraOverride: z.string().optional(),
109
+ duelConfig: z.record(z.any()).optional().describe('Optional Duel / Sparring mode configuration.'),
110
+ voice: z.string().optional().describe('Voice preset or TTS voice name.'),
111
+ voiceId: z.string().optional().describe('Cloned voice ID for neural TTS.'),
112
+ captions: z.boolean().default(true).describe('Include timed captions/subtitles (default: true).'),
113
+ watermark: z.boolean().optional(),
114
+ maxLines: z.number().int().min(1).max(30).optional(),
115
+ beats: z.array(z.record(z.any())).optional().describe('Optional structured beats array with speaker, text, state, cam, sfx.'),
116
+ pipPath: z.string().optional().describe('Local file path for Picture-in-Picture media (png, jpg, webp, mp4, webm).'),
117
+ pipUrl: z.string().optional().describe('Remote URL for Picture-in-Picture media.'),
118
+ pipMeta: z.record(z.any()).optional().describe('Optional Picture-in-Picture metadata for product demos.'),
119
+ customOutro: z.record(z.any()).optional().describe('Optional custom outro card configuration.'),
120
+ },
121
+ async (args) => {
122
+ const sceneSpec = compileSceneSpec(args)
123
+ return jsonToolResult(sceneSpec)
124
+ },
125
+ )
126
+
127
+ // 4. olo_render_video
128
+ server.tool(
129
+ 'olo_render_video',
130
+ 'Render a deterministic 3D video (.mp4, .webm, webm_alpha, alpha, or chroma) via headless Chrome + window.OloStudioBridge.exportSceneBuffer and write it to outputPath.',
131
+ {
132
+ outputPath: z.string().min(1).describe('Destination file path for the rendered video (e.g. ./out/demo.mp4).'),
133
+ format: z.enum(VALID_FORMATS).default('mp4').describe('Output container/mode: mp4, webm, webm_alpha, alpha, chroma.'),
134
+ srt: z.boolean().default(true).describe('Write <outputBase>.srt timed captions alongside the video (default: true).'),
135
+ sceneSpec: z.record(z.any()).optional().describe('Pre-compiled olomni.studio.scene.v1 JSON.'),
136
+ script: z.string().optional().describe('Raw script text if sceneSpec is omitted.'),
137
+ title: z.string().optional(),
138
+ wear: z.string().optional(),
139
+ tint: z.string().optional(),
140
+ finish: z.string().optional(),
141
+ scenario: z.string().optional(),
142
+ glbUrl: z.string().optional().describe('Optional custom mascot .glb/.gltf URL.'),
143
+ glbPath: z.string().optional().describe('Optional local .glb/.gltf file path.'),
144
+ studioUrl: z.string().optional().describe('Optional Studio URL (defaults to local static server or https://www.olomni.com/studio).'),
145
+ timeoutMs: z.number().int().positive().optional(),
146
+ },
147
+ async (args) => {
148
+ try {
149
+ const result = await renderVideoWithBrowser(args)
150
+ return jsonToolResult(result)
151
+ } catch (err) {
152
+ return jsonToolResult(
153
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
154
+ true,
155
+ )
156
+ }
157
+ },
158
+ )
159
+
160
+ // 5. olo_render_batch
161
+ server.tool(
162
+ 'olo_render_batch',
163
+ 'Render multiple SceneSpec variants or a CSV of hooks/languages in a single headless Chrome session.',
164
+ {
165
+ outputDir: z.string().min(1).describe('Directory where rendered variant videos will be saved.'),
166
+ format: z.enum(VALID_FORMATS).default('mp4').describe('Output container/mode: mp4, webm, webm_alpha, alpha, chroma.'),
167
+ specs: z.array(z.record(z.any())).optional().describe('Array of SceneSpec or compile-option objects.'),
168
+ csv: z.string().optional().describe('CSV string with header row (e.g. title,language,script,wear,tint,scenario,voice,voiceId,outputName).'),
169
+ csvPath: z.string().optional().describe('Path to a local CSV file with variant rows.'),
170
+ zip: z.boolean().default(true).describe('Package rendered videos, .srt files, and manifest.json into <outputDir>/batch.zip (default: true).'),
171
+ srt: z.boolean().default(true).describe('Write .srt timed captions alongside each video (default: true).'),
172
+ glbUrl: z.string().optional(),
173
+ glbPath: z.string().optional(),
174
+ studioUrl: z.string().optional(),
175
+ timeoutMs: z.number().int().positive().optional(),
176
+ },
177
+ async (args) => {
178
+ try {
179
+ const result = await renderBatchWithBrowser(args)
180
+ return jsonToolResult(result)
181
+ } catch (err) {
182
+ return jsonToolResult(
183
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
184
+ true,
185
+ )
186
+ }
187
+ },
188
+ )
189
+
190
+ // 6. olo_voice_clone_list
191
+ server.tool(
192
+ 'olo_voice_clone_list',
193
+ 'List or delete cloned voices associated with your OLO_STUDIO_API_KEY via studio-entitlements and studio-voice-clone.',
194
+ {
195
+ action: z.enum(['list', 'delete']).default('list').describe('List cloned voices or delete a voice slot by voiceId.'),
196
+ voiceId: z.string().optional().describe('Voice ID to delete when action is "delete".'),
197
+ apiKey: z.string().optional(),
198
+ apiBase: z.string().optional(),
199
+ },
200
+ async (args) => {
201
+ const result = await manageVoiceClones({
202
+ action: args.action,
203
+ voiceId: args.voiceId,
204
+ apiKey: args.apiKey || serverOpts.apiKey,
205
+ apiBase: args.apiBase || serverOpts.apiBase,
206
+ })
207
+ return jsonToolResult(result, !result.ok && result.error !== 'missing_api_key')
208
+ },
209
+ )
210
+
211
+ // 7. olo_publish_embed
212
+ server.tool(
213
+ 'olo_publish_embed',
214
+ 'Generate the <script src="https://www.olomni.com/studio/embed/olo-presence.js"> + <olo-presence ...> Web Component embed snippet.',
215
+ {
216
+ mascotId: z.string().optional(),
217
+ brand: z.string().optional(),
218
+ glbUrl: z.string().optional(),
219
+ skin: z.string().optional(),
220
+ wear: z.string().optional(),
221
+ tint: z.string().optional(),
222
+ themeColor: z.string().optional(),
223
+ greeting: z.string().optional(),
224
+ ctaLabel: z.string().optional(),
225
+ ctaUrl: z.string().optional(),
226
+ voice: z.string().optional().describe('Voice preset or TTS voice name.'),
227
+ voiceId: z.string().optional().describe('Cloned voice ID for neural TTS.'),
228
+ position: z.enum(['bottom-right', 'bottom-left', 'inline']).optional(),
229
+ apiBase: z.string().optional(),
230
+ apiEndpoint: z.string().optional().describe('Custom chat/inference API endpoint URL.'),
231
+ ttsEndpoint: z.string().optional(),
232
+ systemPrompt: z.string().optional().describe('Custom system prompt for the embedded mascot agent.'),
233
+ knowledge: z.union([z.string(), z.array(z.any()), z.record(z.any())]).optional().describe('Knowledge base text, URL, or JSON payload.'),
234
+ open: z.boolean().optional(),
235
+ },
236
+ async (args) => {
237
+ const result = buildPublishEmbed(args)
238
+ return jsonToolResult(result)
239
+ },
240
+ )
241
+
242
+ return server
243
+ }
244
+
245
+ export async function startStdioServer(opts = {}) {
246
+ const server = createStudioMcpServer(opts)
247
+ const transport = new StdioServerTransport()
248
+ await server.connect(transport)
249
+ return { server, transport }
250
+ }