@olo-studio/studio-mcp 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # `@olo-studio/studio-mcp`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local-first MCP server and CLI for **Olomni Studio** — compile `olomni.studio.scene.v1` scenes offline, generate 9:16 video scripts, render deterministic `.mp4` / `.webm` / alpha / chroma videos via headless Chrome (`window.OloStudioBridge.exportSceneBuffer`), and publish `<olo-presence>` 3D brand mascot embeds.
4
+
5
+ ## Installation & MCP Client Config
6
+
7
+ Add to Claude Desktop, Cursor, Windsurf, or any MCP-compatible host:
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "olomni-studio": {
13
+ "command": "npx",
14
+ "args": ["-y", "@olo-studio/studio-mcp"],
15
+ "env": {
16
+ "OLO_STUDIO_API_KEY": "olo_pat_your_token_here"
17
+ }
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ ## Tools (7)
24
+
25
+ | Tool | Description |
26
+ | --- | --- |
27
+ | `olo_get_entitlements` | Inspect account tier, capabilities (`maxDuration`, `allowGLB`, `allowBatch`, `allowAlphaOrChroma`), and upgrade URLs (`/studio`, `/mcp`). Falls back to free-tier capabilities offline. |
28
+ | `olo_generate_script` | Generate 9:16 scripts (`mode: "product" \| "chisme" \| "changelog"`) via `/functions/v1/studio-chisme-ai` with deterministic offline fallback. |
29
+ | `olo_compile_scene` | Compile and validate `olomni.studio.scene.v1` SceneSpec JSON offline without a browser. |
30
+ | `olo_render_video` | Render a real `.mp4`, `.webm`, `alpha`, or `chroma` video via headless Chrome (`OloStudioBridge.exportSceneBuffer`) to `outputPath`. |
31
+ | `olo_render_batch` | Render multi-variant hooks/languages from a SceneSpec array or CSV in a single browser session. |
32
+ | `olo_voice_clone_list` | List or delete cloned voices (`OLO_STUDIO_API_KEY` required) via `studio-entitlements` & `studio-voice-clone`. |
33
+ | `olo_publish_embed` | Generate the `<script src="https://www.olomni.com/studio/embed/olo-presence.js" defer></script>` + `<olo-presence ...>` snippet. |
34
+
35
+ ## CLI Usage
36
+
37
+ ```bash
38
+ olo-studio-mcp --help
39
+ olo-studio-mcp serve
40
+ olo-studio-mcp compile --title "Launch" --script "Olo: Ship 3D reels in seconds." --out scene.json
41
+ olo-studio-mcp render --file scene.json --out demo.mp4 --format mp4
42
+ olo-studio-mcp inspect-glb ./mascot.glb
43
+ ```
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @olo-studio/studio-mcp CLI & Stdio MCP Server Entrypoint
4
+ * Usage:
5
+ * olo-studio-mcp [serve] Start JSON-RPC 2.0 stdio MCP server
6
+ * olo-studio-mcp compile [opts] Compile SceneSpec v1 JSON offline
7
+ * olo-studio-mcp render [opts] Render MP4/WebM via headless Chrome
8
+ * olo-studio-mcp inspect-glb <src> Inspect .glb header & JSON chunk
9
+ * olo-studio-mcp --help Show help
10
+ */
11
+ import fs from 'node:fs'
12
+ import fsp from 'node:fs/promises'
13
+ import path from 'node:path'
14
+ import { compileSceneSpec } from '../src/compiler.js'
15
+ import { inspectGlb, renderVideoWithBrowser } from '../src/renderer.js'
16
+ import { startStdioServer } from '../src/server.js'
17
+
18
+ const PKG_VERSION = JSON.parse(
19
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
20
+ ).version
21
+
22
+ function parseFlags(argv) {
23
+ const flags = {}
24
+ const positional = []
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const arg = argv[i]
27
+ if (arg === '--help' || arg === '-h') {
28
+ flags.help = true
29
+ } else if (arg.startsWith('--')) {
30
+ const key = arg.slice(2)
31
+ const next = argv[i + 1]
32
+ if (next !== undefined && !next.startsWith('--')) {
33
+ flags[key] = next
34
+ i++
35
+ } else {
36
+ flags[key] = true
37
+ }
38
+ } else {
39
+ positional.push(arg)
40
+ }
41
+ }
42
+ return { flags, positional }
43
+ }
44
+
45
+ function printHelp() {
46
+ console.log(`@olo-studio/studio-mcp v${PKG_VERSION} — Local-First MCP Server & CLI for Olomni Studio
47
+
48
+ Usage:
49
+ olo-studio-mcp [serve]
50
+ Start the JSON-RPC 2.0 stdio MCP server (default when no subcommand is given).
51
+
52
+ olo-studio-mcp compile [--script "..."] [--file scene.json] [--title "..."] [--out out.json]
53
+ Compile and validate an olomni.studio.scene.v1 SceneSpec without a browser.
54
+
55
+ olo-studio-mcp render [--script "..."] [--file scene.json] [--out out.mp4] [--format mp4|webm|alpha|chroma] [--fps 24|30|60] [--quality fast|social|studio] [--glb path-or-url]
56
+ Render a deterministic 3D video via headless Chrome + OloStudioBridge.
57
+
58
+ olo-studio-mcp inspect-glb <path-or-url>
59
+ Inspect a .glb binary header, scenes, nodes, meshes, animations, and materials.
60
+
61
+ Environment Variables:
62
+ OLO_STUDIO_API_KEY Personal Access Token (olo_pat_...) for Pro/Agency entitlements
63
+ OLO_STUDIO_API_BASE API base URL (default: https://www.olomni.com)
64
+ OLO_STUDIO_URL Override Studio URL for headless rendering
65
+ OLO_STUDIO_PUBLIC_DIR Override local public/ directory for static serving
66
+ OLO_CHROME_PATH Override Chrome/Chromium executable path`)
67
+ }
68
+
69
+ async function loadInputSpec(flags) {
70
+ if (flags.file) {
71
+ const raw = await fsp.readFile(path.resolve(String(flags.file)), 'utf8')
72
+ return JSON.parse(raw)
73
+ }
74
+ return {
75
+ ...(flags.title ? { title: String(flags.title) } : {}),
76
+ ...(flags.script ? { script: String(flags.script) } : {}),
77
+ ...(flags.wear ? { wear: String(flags.wear) } : {}),
78
+ ...(flags.tint ? { tint: String(flags.tint) } : {}),
79
+ ...(flags.finish ? { finish: String(flags.finish) } : {}),
80
+ ...(flags.scenario ? { scenario: String(flags.scenario) } : {}),
81
+ }
82
+ }
83
+
84
+ async function main() {
85
+ const { flags, positional } = parseFlags(process.argv.slice(2))
86
+ const cmd = positional[0] || 'serve'
87
+
88
+ if (flags.help || cmd === 'help') {
89
+ printHelp()
90
+ return
91
+ }
92
+
93
+ if (cmd === 'serve') {
94
+ await startStdioServer()
95
+ return
96
+ }
97
+
98
+ if (cmd === 'compile') {
99
+ const input = await loadInputSpec(flags)
100
+ const sceneSpec = compileSceneSpec(input)
101
+ const json = JSON.stringify(sceneSpec, null, 2)
102
+ if (flags.out) {
103
+ const outPath = path.resolve(String(flags.out))
104
+ await fsp.mkdir(path.dirname(outPath), { recursive: true })
105
+ await fsp.writeFile(outPath, json, 'utf8')
106
+ console.log(JSON.stringify({ ok: true, outPath, sceneHash: sceneSpec.sceneHash, duration: sceneSpec.duration }))
107
+ } else {
108
+ console.log(json)
109
+ }
110
+ return
111
+ }
112
+
113
+ if (cmd === 'render') {
114
+ const input = await loadInputSpec(flags)
115
+ const glbArg = flags.glb ? String(flags.glb) : undefined
116
+ const isUrl = glbArg && /^https?:\/\//i.test(glbArg)
117
+ const result = await renderVideoWithBrowser({
118
+ sceneSpec: input,
119
+ format: flags.format ? String(flags.format) : 'mp4',
120
+ outputPath: flags.out ? String(flags.out) : './olo-render.mp4',
121
+ ...(flags.fps !== undefined ? { fps: Number(flags.fps) } : {}),
122
+ ...(flags.quality ? { quality: String(flags.quality) } : {}),
123
+ ...(isUrl ? { glbUrl: glbArg } : glbArg ? { glbPath: glbArg } : {}),
124
+ ...(flags.studioUrl ? { studioUrl: String(flags.studioUrl) } : {}),
125
+ })
126
+ console.log(JSON.stringify(result, null, 2))
127
+ return
128
+ }
129
+
130
+ if (cmd === 'inspect-glb') {
131
+ const target = positional[1] || flags.file || flags.url
132
+ const info = await inspectGlb(String(target || ''))
133
+ console.log(JSON.stringify(info, null, 2))
134
+ return
135
+ }
136
+
137
+ console.error(`Unknown subcommand: ${cmd}`)
138
+ printHelp()
139
+ process.exitCode = 1
140
+ }
141
+
142
+ main().catch((err) => {
143
+ console.error(err instanceof Error ? err.message : String(err))
144
+ process.exit(1)
145
+ })
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@olo-studio/studio-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Local-first MCP server and CLI for Olomni Studio 3D video rendering, SceneSpec v1 compilation, AI scripting, and <olo-presence> embeds.",
5
+ "type": "module",
6
+ "main": "./src/server.js",
7
+ "scripts": {
8
+ "prepack": "node -e \"const fs=require('fs'),p='../../public/studio/engine/director.js';if(fs.existsSync(p))fs.copyFileSync(p,'./src/director.js')\""
9
+ },
10
+ "exports": {
11
+ ".": "./src/server.js",
12
+ "./compiler": "./src/compiler.js",
13
+ "./renderer": "./src/renderer.js"
14
+ },
15
+ "bin": {
16
+ "olo-studio-mcp": "bin/olo-studio-mcp.js"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "src/",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "mcp",
25
+ "olomni",
26
+ "olo-studio",
27
+ "3d-video",
28
+ "webgl",
29
+ "webcodecs",
30
+ "ai-agents"
31
+ ],
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/faustodariopenamondragon-web/OlomniAI.git",
39
+ "directory": "packages/studio-mcp"
40
+ },
41
+ "engines": {
42
+ "node": ">=20.0.0"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.12.0",
46
+ "jszip": "^3.10.1",
47
+ "playwright": "^1.64.1",
48
+ "zod": "^3.24.2"
49
+ }
50
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Olomni Studio API Client (entitlements, AI script generation, voice clones).
3
+ * Supports OLO_STUDIO_API_KEY (olo_pat_...) and offline deterministic fallbacks.
4
+ */
5
+
6
+ const DEFAULT_API_BASE = 'https://www.olomni.com'
7
+
8
+ export function resolveApiBase(overrideBase) {
9
+ const raw = overrideBase || process.env.OLO_STUDIO_API_BASE || process.env.OLO_API_BASE || DEFAULT_API_BASE
10
+ return String(raw).replace(/\/+$/, '')
11
+ }
12
+
13
+ export function resolveApiKey(overrideKey) {
14
+ const key = overrideKey || process.env.OLO_STUDIO_API_KEY || ''
15
+ return String(key).trim()
16
+ }
17
+
18
+ export function getFreeTierEntitlements(extra = {}) {
19
+ return {
20
+ authenticated: false,
21
+ tier: 'free',
22
+ isPass: false,
23
+ isPro: false,
24
+ isAgency: false,
25
+ maxClones: 0,
26
+ maxDuration: 30,
27
+ allowAlphaOrChroma: false,
28
+ allowGLB: false,
29
+ allowBatch: false,
30
+ voiceMinutesLimit: 0,
31
+ voiceMinutesUsed: 0,
32
+ clonedVoices: [],
33
+ stripeEnabled: false,
34
+ upgradeUrl: 'https://www.olomni.com/studio',
35
+ mcpDocsUrl: 'https://www.olomni.com/mcp',
36
+ ...extra,
37
+ }
38
+ }
39
+
40
+ export async function getEntitlements(opts = {}) {
41
+ const apiBase = resolveApiBase(opts.apiBase)
42
+ const apiKey = resolveApiKey(opts.apiKey)
43
+ const fetchFn = opts.fetchFn || globalThis.fetch
44
+
45
+ if (!apiKey || typeof fetchFn !== 'function') {
46
+ return getFreeTierEntitlements({
47
+ source: apiKey ? 'offline_no_fetch' : 'offline_free_tier',
48
+ })
49
+ }
50
+
51
+ try {
52
+ const res = await fetchFn(`${apiBase}/functions/v1/studio-entitlements`, {
53
+ method: 'GET',
54
+ headers: {
55
+ Authorization: `Bearer ${apiKey}`,
56
+ Accept: 'application/json',
57
+ },
58
+ signal: AbortSignal.timeout(opts.timeoutMs || 8000),
59
+ })
60
+
61
+ if (!res.ok) {
62
+ return getFreeTierEntitlements({
63
+ source: 'remote_error',
64
+ httpStatus: res.status,
65
+ })
66
+ }
67
+
68
+ const data = await res.json()
69
+ return {
70
+ ...getFreeTierEntitlements(),
71
+ ...data,
72
+ stripeEnabled: false,
73
+ upgradeUrl: 'https://www.olomni.com/studio',
74
+ mcpDocsUrl: 'https://www.olomni.com/mcp',
75
+ source: 'remote',
76
+ }
77
+ } catch (err) {
78
+ return getFreeTierEntitlements({
79
+ source: 'offline_fallback',
80
+ error: err instanceof Error ? err.message : String(err),
81
+ })
82
+ }
83
+ }
84
+
85
+ const OFFLINE_TEMPLATES = {
86
+ product(topic, language) {
87
+ const isEs = String(language || 'es').toLowerCase().startsWith('es')
88
+ if (isEs) {
89
+ return {
90
+ title: topic ? `${topic} — Demo 9:16` : 'Lanzamiento de Producto 9:16',
91
+ beats: [
92
+ { speaker: 'Olo', line: `¿Todavía pierdes horas haciendo ${topic || 'esto'} a mano?`, state: 'alert', emotion: 'shocked' },
93
+ { speaker: 'Yo', line: 'Sí, cada lanzamiento me toma días enteros.', state: 'error', emotion: 'cringe' },
94
+ { speaker: 'Olo', line: `Con ${topic || 'Olomni Studio'} lo automatizas en 30 segundos.`, state: 'proud', emotion: 'proud' },
95
+ { speaker: 'Olo', line: 'Pruébalo hoy gratis y publica tu primer reel.', state: 'done', emotion: 'happy' },
96
+ ],
97
+ }
98
+ }
99
+ return {
100
+ title: topic ? `${topic} — 9:16 Demo` : 'Product Launch 9:16',
101
+ beats: [
102
+ { speaker: 'Olo', line: `Still spending hours doing ${topic || 'this'} manually?`, state: 'alert', emotion: 'shocked' },
103
+ { speaker: 'Yo', line: 'Yeah, every release takes way too long.', state: 'error', emotion: 'cringe' },
104
+ { speaker: 'Olo', line: `With ${topic || 'Olomni Studio'} you ship it in 30 seconds.`, state: 'proud', emotion: 'proud' },
105
+ { speaker: 'Olo', line: 'Try it free today and publish your first 3D reel.', state: 'done', emotion: 'happy' },
106
+ ],
107
+ }
108
+ },
109
+ changelog(topic, language) {
110
+ const isEs = String(language || 'es').toLowerCase().startsWith('es')
111
+ if (isEs) {
112
+ return {
113
+ title: topic ? `Novedades: ${topic}` : 'Changelog Release 9:16',
114
+ beats: [
115
+ { speaker: 'Olo', line: `Acabamos de lanzar ${topic || 'la nueva versión'} y cambia todo.`, state: 'alert', emotion: 'alert' },
116
+ { speaker: 'Olo', line: 'Exportación determinista, lip-sync neural y cero fricción.', state: 'proud', emotion: 'proud' },
117
+ { speaker: 'Olo', line: 'Actualiza ahora y pruébalo en tu próximo deploy.', state: 'done', emotion: 'happy' },
118
+ ],
119
+ }
120
+ }
121
+ return {
122
+ title: topic ? `What's New: ${topic}` : 'Changelog Release 9:16',
123
+ beats: [
124
+ { speaker: 'Olo', line: `We just shipped ${topic || 'our latest release'} and it changes everything.`, state: 'alert', emotion: 'alert' },
125
+ { speaker: 'Olo', line: 'Deterministic export, neural lip-sync, zero friction.', state: 'proud', emotion: 'proud' },
126
+ { speaker: 'Olo', line: 'Update now and test it on your next deploy.', state: 'done', emotion: 'happy' },
127
+ ],
128
+ }
129
+ },
130
+ chisme(topic, language) {
131
+ const isEs = String(language || 'es').toLowerCase().startsWith('es')
132
+ if (isEs) {
133
+ return {
134
+ title: topic ? `Chisme: ${topic}` : 'Storytime 9:16',
135
+ beats: [
136
+ { speaker: 'Yo', line: `No vas a creer lo que pasó con ${topic || 'el cliente hoy'}.`, state: 'curious', emotion: 'curious' },
137
+ { speaker: 'Contacto', line: 'Cuéntamelo todo sin censura, ¿qué hizo ahora?', state: 'alert', emotion: 'shocked' },
138
+ { speaker: 'Olo', line: 'Spoiler: terminó pidiendo perdón y pagando el doble.', state: 'curious', emotion: 'sarcastic' },
139
+ { speaker: 'Olo', line: 'Guarda este video antes de que lo borren.', state: 'proud', emotion: 'proud' },
140
+ ],
141
+ }
142
+ }
143
+ return {
144
+ title: topic ? `Storytime: ${topic}` : 'Storytime 9:16',
145
+ beats: [
146
+ { speaker: 'Yo', line: `You will not believe what happened with ${topic || 'our launch today'}.`, state: 'curious', emotion: 'curious' },
147
+ { speaker: 'Contacto', line: 'Wait, spill everything right now.', state: 'alert', emotion: 'shocked' },
148
+ { speaker: 'Olo', line: 'Spoiler: they came back asking for an enterprise plan.', state: 'curious', emotion: 'sarcastic' },
149
+ { speaker: 'Olo', line: 'Save this clip before it disappears.', state: 'proud', emotion: 'proud' },
150
+ ],
151
+ }
152
+ },
153
+ }
154
+
155
+ export async function generateScript(opts = {}) {
156
+ const mode = ['product', 'chisme', 'changelog'].includes(opts.mode) ? opts.mode : 'product'
157
+ const topic = String(opts.topic || opts.product || opts.text || '').trim()
158
+ const language = String(opts.language || 'es').trim()
159
+ const apiBase = resolveApiBase(opts.apiBase)
160
+ const apiKey = resolveApiKey(opts.apiKey)
161
+ const fetchFn = opts.fetchFn || globalThis.fetch
162
+
163
+ if (typeof fetchFn === 'function' && opts.offlineOnly !== true) {
164
+ try {
165
+ const headers = {
166
+ 'Content-Type': 'application/json',
167
+ Accept: 'application/json',
168
+ }
169
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
170
+
171
+ const res = await fetchFn(`${apiBase}/functions/v1/studio-chisme-ai`, {
172
+ method: 'POST',
173
+ headers,
174
+ body: JSON.stringify({
175
+ mode,
176
+ text: topic || `Generate a ${mode} 9:16 script in ${language}`,
177
+ language,
178
+ }),
179
+ signal: AbortSignal.timeout(opts.timeoutMs || 12000),
180
+ })
181
+
182
+ if (res.ok) {
183
+ const data = await res.json()
184
+ if (data && typeof data.script === 'string' && data.script.trim()) {
185
+ return {
186
+ ok: true,
187
+ mode: data.mode || mode,
188
+ title: data.title || topic || 'Olo Studio Show',
189
+ beats: Array.isArray(data.beats) ? data.beats : [],
190
+ script: data.script,
191
+ provider: data.provider || 'remote',
192
+ }
193
+ }
194
+ }
195
+ } catch {
196
+ // Fall through to deterministic offline generator
197
+ }
198
+ }
199
+
200
+ const builder = OFFLINE_TEMPLATES[mode] || OFFLINE_TEMPLATES.product
201
+ const generated = builder(topic, language)
202
+ const script = generated.beats.map((b) => `${b.speaker}: ${b.line}`).join('\n')
203
+ return {
204
+ ok: true,
205
+ mode,
206
+ title: generated.title,
207
+ beats: generated.beats,
208
+ script,
209
+ provider: 'offline-deterministic',
210
+ }
211
+ }
212
+
213
+ export async function manageVoiceClones(opts = {}) {
214
+ const action = opts.action === 'delete' ? 'delete' : 'list'
215
+ const apiBase = resolveApiBase(opts.apiBase)
216
+ const apiKey = resolveApiKey(opts.apiKey)
217
+ const fetchFn = opts.fetchFn || globalThis.fetch
218
+
219
+ if (!apiKey) {
220
+ return {
221
+ ok: false,
222
+ error: 'missing_api_key',
223
+ message: 'Set OLO_STUDIO_API_KEY (olo_pat_...) to list or manage cloned voices.',
224
+ clonedVoices: [],
225
+ maxClones: 0,
226
+ upgradeUrl: 'https://www.olomni.com/studio',
227
+ }
228
+ }
229
+
230
+ if (typeof fetchFn !== 'function') {
231
+ return { ok: false, error: 'fetch_unavailable', clonedVoices: [] }
232
+ }
233
+
234
+ if (action === 'delete') {
235
+ const voiceId = String(opts.voiceId || '').trim()
236
+ if (!voiceId) {
237
+ return { ok: false, error: 'missing_voice_id', message: 'voiceId is required when action is delete.' }
238
+ }
239
+ const res = await fetchFn(
240
+ `${apiBase}/functions/v1/studio-voice-clone?voiceId=${encodeURIComponent(voiceId)}`,
241
+ {
242
+ method: 'DELETE',
243
+ headers: {
244
+ Authorization: `Bearer ${apiKey}`,
245
+ Accept: 'application/json',
246
+ },
247
+ signal: AbortSignal.timeout(opts.timeoutMs || 10000),
248
+ },
249
+ )
250
+ const body = await res.json().catch(() => ({}))
251
+ return {
252
+ ok: res.ok,
253
+ status: res.status,
254
+ action: 'delete',
255
+ voiceId,
256
+ ...body,
257
+ }
258
+ }
259
+
260
+ const entitlements = await getEntitlements({ apiBase, apiKey, fetchFn, timeoutMs: opts.timeoutMs })
261
+ return {
262
+ ok: Boolean(entitlements.authenticated),
263
+ action: 'list',
264
+ tier: entitlements.tier,
265
+ maxClones: entitlements.maxClones || 0,
266
+ voiceMinutesLimit: entitlements.voiceMinutesLimit || 0,
267
+ voiceMinutesUsed: entitlements.voiceMinutesUsed || 0,
268
+ clonedVoices: Array.isArray(entitlements.clonedVoices) ? entitlements.clonedVoices : [],
269
+ upgradeUrl: 'https://www.olomni.com/studio',
270
+ }
271
+ }