@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/src/server.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * @olo-studio/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 fs from 'node:fs'
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
+ import { z } from 'zod'
9
+ import { getEntitlements, generateScript, manageVoiceClones } from './api-client.js'
10
+
11
+ const PKG_VERSION = JSON.parse(
12
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
13
+ ).version
14
+ import {
15
+ compileSceneSpec,
16
+ buildPublishEmbed,
17
+ VALID_STATES,
18
+ VALID_TINTS,
19
+ VALID_WEARS,
20
+ VALID_FINISHES,
21
+ VALID_SCENARIOS,
22
+ STATE_ALIASES,
23
+ TINT_ALIASES,
24
+ WEAR_ALIASES,
25
+ FINISH_ALIASES,
26
+ SCENARIO_ALIASES,
27
+ } from './compiler.js'
28
+ import { VALID_FORMATS, VALID_QUALITIES, renderVideoWithBrowser, renderBatchWithBrowser } from './renderer.js'
29
+
30
+ function enumWithAliases(canonical, aliases, label) {
31
+ return z
32
+ .enum([...canonical, ...Object.keys(aliases)])
33
+ .optional()
34
+ .describe(`${label} (canonical: ${canonical.join(', ')})`)
35
+ }
36
+
37
+ function jsonToolResult(payload, isError = false) {
38
+ return {
39
+ content: [
40
+ {
41
+ type: 'text',
42
+ text: JSON.stringify(payload, null, 2),
43
+ },
44
+ ],
45
+ ...(isError ? { isError: true } : {}),
46
+ }
47
+ }
48
+
49
+ export function createStudioMcpServer(serverOpts = {}) {
50
+ const server = new McpServer({
51
+ name: '@olo-studio/studio-mcp',
52
+ version: PKG_VERSION,
53
+ })
54
+
55
+ // 1. olo_get_entitlements
56
+ server.tool(
57
+ 'olo_get_entitlements',
58
+ 'Inspect Olomni Studio account tier, capabilities (maxDuration, allowGLB, allowBatch, allowAlphaOrChroma), and upgrade URLs using OLO_STUDIO_API_KEY (or free-tier fallback).',
59
+ {
60
+ apiKey: z.string().optional().describe('Optional olo_pat_... token (defaults to OLO_STUDIO_API_KEY env var).'),
61
+ apiBase: z.string().optional().describe('Optional API base URL (defaults to https://www.olomni.com).'),
62
+ },
63
+ async (args) => {
64
+ const result = await getEntitlements({
65
+ apiKey: args.apiKey || serverOpts.apiKey,
66
+ apiBase: args.apiBase || serverOpts.apiBase,
67
+ })
68
+ return jsonToolResult(result)
69
+ },
70
+ )
71
+
72
+ // 2. olo_generate_script
73
+ server.tool(
74
+ 'olo_generate_script',
75
+ 'Generate a 9:16 video script (Olo: ... / Yo: ...) in product, chisme, or changelog mode via studio-chisme-ai with deterministic offline fallback.',
76
+ {
77
+ mode: z.enum(['product', 'chisme', 'changelog']).default('product').describe('Script style mode.'),
78
+ topic: z.string().min(1).describe('Product, feature, changelog, or story topic.'),
79
+ language: z.string().default('es').describe('Output language code (e.g. es, en, pt).'),
80
+ offlineOnly: z.boolean().optional().describe('Skip network call and use deterministic offline generator.'),
81
+ apiKey: z.string().optional(),
82
+ apiBase: z.string().optional(),
83
+ },
84
+ async (args) => {
85
+ const result = await generateScript({
86
+ mode: args.mode,
87
+ topic: args.topic,
88
+ language: args.language,
89
+ offlineOnly: args.offlineOnly,
90
+ apiKey: args.apiKey || serverOpts.apiKey,
91
+ apiBase: args.apiBase || serverOpts.apiBase,
92
+ })
93
+ return jsonToolResult(result)
94
+ },
95
+ )
96
+
97
+ // 3. olo_compile_scene
98
+ server.tool(
99
+ 'olo_compile_scene',
100
+ 'Compile and validate a deterministic olomni.studio.scene.v1 SceneSpec JSON from script text or structured beats without launching a browser.',
101
+ {
102
+ title: z.string().optional().describe('Show title (max 60 chars).'),
103
+ category: z.string().optional().describe('Category badge (e.g. Show 9:16, Product Demo).'),
104
+ script: z.string().optional().describe('Line-delimited script (e.g. "Olo: Hola\\nYo: Qué tal").'),
105
+ wear: enumWithAliases(VALID_WEARS, WEAR_ALIASES, 'Mascot wear accessory'),
106
+ tint: enumWithAliases(VALID_TINTS, TINT_ALIASES, 'Mascot body tint'),
107
+ finish: enumWithAliases(VALID_FINISHES, FINISH_ALIASES, 'Collector PBR finish'),
108
+ scenario: enumWithAliases(VALID_SCENARIOS, SCENARIO_ALIASES, '3D background scenario'),
109
+ state: enumWithAliases(VALID_STATES, STATE_ALIASES, 'Default 3D character state'),
110
+ stateOverride: enumWithAliases(VALID_STATES, STATE_ALIASES, 'Override 3D character state'),
111
+ emotion: z.string().optional().describe('Legacy alias for stateOverride.'),
112
+ vibe: z.string().optional(),
113
+ cameraOverride: z.string().optional(),
114
+ duelConfig: z.record(z.any()).optional().describe('Optional Duel / Sparring mode configuration.'),
115
+ voice: z.string().optional().describe('Voice preset or TTS voice name.'),
116
+ voiceId: z.string().optional().describe('Cloned voice ID for neural TTS.'),
117
+ captions: z.boolean().default(true).describe('Include timed captions/subtitles (default: true).'),
118
+ watermark: z.boolean().optional(),
119
+ maxLines: z.number().int().min(1).max(30).optional(),
120
+ beats: z.array(z.record(z.any())).optional().describe('Optional structured beats array with speaker, text, state, cam, sfx.'),
121
+ pipPath: z.string().optional().describe('Local file path for Picture-in-Picture media (png, jpg, webp, mp4, webm).'),
122
+ pipUrl: z.string().optional().describe('Remote URL for Picture-in-Picture media.'),
123
+ pipMeta: z.record(z.any()).optional().describe('Optional Picture-in-Picture metadata for product demos.'),
124
+ customOutro: z.record(z.any()).optional().describe('Optional custom outro card configuration.'),
125
+ },
126
+ async (args) => {
127
+ const sceneSpec = compileSceneSpec(args)
128
+ return jsonToolResult(sceneSpec)
129
+ },
130
+ )
131
+
132
+ // 4. olo_render_video
133
+ server.tool(
134
+ 'olo_render_video',
135
+ 'Render a deterministic 3D video (.mp4, .webm, webm_alpha, alpha, or chroma) via headless Chrome + window.OloStudioBridge.exportSceneBuffer and write it to outputPath.',
136
+ {
137
+ outputPath: z.string().min(1).describe('Destination file path for the rendered video (e.g. ./out/demo.mp4).'),
138
+ format: z.enum(VALID_FORMATS).default('mp4').describe('Output container/mode: mp4, webm, webm_alpha, alpha, chroma.'),
139
+ fps: z.union([z.literal(24), z.literal(30), z.literal(60)]).default(30).describe('Frames per second: 24, 30, or 60 (default: 30).'),
140
+ quality: z.enum(VALID_QUALITIES).default('social').describe('Render quality preset: fast, social, or studio (default: social).'),
141
+ srt: z.boolean().default(true).describe('Write <outputBase>.srt timed captions alongside the video (default: true).'),
142
+ sceneSpec: z.record(z.any()).optional().describe('Pre-compiled olomni.studio.scene.v1 JSON.'),
143
+ script: z.string().optional().describe('Raw script text if sceneSpec is omitted.'),
144
+ title: z.string().optional(),
145
+ wear: z.string().optional(),
146
+ tint: z.string().optional(),
147
+ finish: z.string().optional(),
148
+ scenario: z.string().optional(),
149
+ voice: z.string().optional().describe('Voice preset or TTS voice name.'),
150
+ voiceId: z.string().optional().describe('Cloned voice ID for neural TTS.'),
151
+ captions: z.boolean().optional().describe('Include timed captions/subtitles (default: true).'),
152
+ pipPath: z.string().optional().describe('Local file path for Picture-in-Picture media (png, jpg, webp, mp4, webm).'),
153
+ pipUrl: z.string().optional().describe('Remote URL for Picture-in-Picture media.'),
154
+ glbUrl: z.string().optional().describe('Optional custom mascot .glb/.gltf URL.'),
155
+ glbPath: z.string().optional().describe('Optional local .glb/.gltf file path.'),
156
+ studioUrl: z.string().optional().describe('Optional Studio URL (defaults to local static server or https://www.olomni.com/studio).'),
157
+ apiKey: z.string().optional().describe('Optional olo_pat_... token (defaults to OLO_STUDIO_API_KEY env var).'),
158
+ timeoutMs: z.number().int().positive().optional(),
159
+ },
160
+ async (args) => {
161
+ try {
162
+ const result = await renderVideoWithBrowser({
163
+ ...args,
164
+ apiKey: args.apiKey || serverOpts.apiKey,
165
+ })
166
+ return jsonToolResult(result)
167
+ } catch (err) {
168
+ return jsonToolResult(
169
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
170
+ true,
171
+ )
172
+ }
173
+ },
174
+ )
175
+
176
+ // 5. olo_render_batch
177
+ server.tool(
178
+ 'olo_render_batch',
179
+ 'Render multiple SceneSpec variants or a CSV of hooks/languages in a single headless Chrome session.',
180
+ {
181
+ outputDir: z.string().min(1).describe('Directory where rendered variant videos will be saved.'),
182
+ format: z.enum(VALID_FORMATS).default('mp4').describe('Output container/mode: mp4, webm, webm_alpha, alpha, chroma.'),
183
+ fps: z.union([z.literal(24), z.literal(30), z.literal(60)]).default(30).describe('Frames per second: 24, 30, or 60 (default: 30).'),
184
+ quality: z.enum(VALID_QUALITIES).default('social').describe('Render quality preset: fast, social, or studio (default: social).'),
185
+ specs: z.array(z.record(z.any())).optional().describe('Array of SceneSpec or compile-option objects.'),
186
+ csv: z.string().optional().describe('CSV string with header row (e.g. title,language,script,wear,tint,scenario,voice,voiceId,outputName).'),
187
+ csvPath: z.string().optional().describe('Path to a local CSV file with variant rows.'),
188
+ zip: z.boolean().default(true).describe('Package rendered videos, .srt files, and manifest.json into <outputDir>/batch.zip (default: true).'),
189
+ srt: z.boolean().default(true).describe('Write .srt timed captions alongside each video (default: true).'),
190
+ voice: z.string().optional().describe('Default voice preset or TTS voice name for variants.'),
191
+ voiceId: z.string().optional().describe('Default cloned voice ID for neural TTS.'),
192
+ captions: z.boolean().optional().describe('Include timed captions/subtitles by default (default: true).'),
193
+ pipPath: z.string().optional().describe('Default local file path for Picture-in-Picture media (png, jpg, webp, mp4, webm).'),
194
+ pipUrl: z.string().optional().describe('Default remote URL for Picture-in-Picture media.'),
195
+ glbUrl: z.string().optional(),
196
+ glbPath: z.string().optional(),
197
+ studioUrl: z.string().optional(),
198
+ apiKey: z.string().optional().describe('Optional olo_pat_... token (defaults to OLO_STUDIO_API_KEY env var).'),
199
+ timeoutMs: z.number().int().positive().optional(),
200
+ },
201
+ async (args) => {
202
+ try {
203
+ const result = await renderBatchWithBrowser({
204
+ ...args,
205
+ apiKey: args.apiKey || serverOpts.apiKey,
206
+ })
207
+ return jsonToolResult(result)
208
+ } catch (err) {
209
+ return jsonToolResult(
210
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
211
+ true,
212
+ )
213
+ }
214
+ },
215
+ )
216
+
217
+ // 6. olo_voice_clone_list
218
+ server.tool(
219
+ 'olo_voice_clone_list',
220
+ 'List or delete cloned voices associated with your OLO_STUDIO_API_KEY via studio-entitlements and studio-voice-clone.',
221
+ {
222
+ action: z.enum(['list', 'delete']).default('list').describe('List cloned voices or delete a voice slot by voiceId.'),
223
+ voiceId: z.string().optional().describe('Voice ID to delete when action is "delete".'),
224
+ apiKey: z.string().optional(),
225
+ apiBase: z.string().optional(),
226
+ },
227
+ async (args) => {
228
+ const result = await manageVoiceClones({
229
+ action: args.action,
230
+ voiceId: args.voiceId,
231
+ apiKey: args.apiKey || serverOpts.apiKey,
232
+ apiBase: args.apiBase || serverOpts.apiBase,
233
+ })
234
+ return jsonToolResult(result, !result.ok && result.error !== 'missing_api_key')
235
+ },
236
+ )
237
+
238
+ // 7. olo_publish_embed
239
+ server.tool(
240
+ 'olo_publish_embed',
241
+ 'Generate the <script src="https://www.olomni.com/studio/embed/olo-presence.js"> + <olo-presence ...> Web Component embed snippet.',
242
+ {
243
+ mascotId: z.string().optional(),
244
+ brand: z.string().optional(),
245
+ glbUrl: z.string().optional(),
246
+ skin: z.string().optional(),
247
+ wear: z.string().optional(),
248
+ tint: z.string().optional(),
249
+ themeColor: z.string().optional(),
250
+ greeting: z.string().optional(),
251
+ ctaLabel: z.string().optional(),
252
+ ctaUrl: z.string().optional(),
253
+ voice: z.string().optional().describe('Voice preset or TTS voice name.'),
254
+ voiceId: z.string().optional().describe('Cloned voice ID for neural TTS.'),
255
+ position: z.enum(['bottom-right', 'bottom-left', 'inline']).optional(),
256
+ apiBase: z.string().optional(),
257
+ apiEndpoint: z.string().optional().describe('Custom chat/inference API endpoint URL.'),
258
+ ttsEndpoint: z.string().optional(),
259
+ systemPrompt: z.string().optional().describe('Custom system prompt for the embedded mascot agent.'),
260
+ knowledge: z.union([z.string(), z.array(z.any()), z.record(z.any())]).optional().describe('Knowledge base text, URL, or JSON payload.'),
261
+ open: z.boolean().optional(),
262
+ },
263
+ async (args) => {
264
+ const result = buildPublishEmbed(args)
265
+ return jsonToolResult(result)
266
+ },
267
+ )
268
+
269
+ return server
270
+ }
271
+
272
+ export async function startStdioServer(opts = {}) {
273
+ const server = createStudioMcpServer(opts)
274
+ const transport = new StdioServerTransport()
275
+ await server.connect(transport)
276
+ return { server, transport }
277
+ }