@clipform/mcp-server 1.9.0 → 1.9.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.
@@ -1,3824 +0,0 @@
1
- import {
2
- getMcpAuth
3
- } from "./chunk-MYWOSQ66.js";
4
-
5
- // src/server.ts
6
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
- import { readFileSync } from "fs";
8
- import { dirname, join } from "path";
9
- import { fileURLToPath } from "url";
10
-
11
- // src/tools/create-form.ts
12
- import { z as z2 } from "zod";
13
-
14
- // ../config/node-types.js
15
- var NODE_TYPES = {
16
- start: {
17
- label: "Start",
18
- shorthand: "Start",
19
- icon: "ArrowRight",
20
- color: "#D1FAE5",
21
- description: "Entry point for the form - connects to the first question",
22
- category: "start",
23
- sort_order: 0,
24
- has_options: false,
25
- is_terminal: false,
26
- show_nav_bar: false,
27
- loading: "eager",
28
- supports_prompt: false,
29
- supports_media: false,
30
- is_system: true,
31
- is_active: true,
32
- default_config: null,
33
- config_schema: null,
34
- response_schema: null,
35
- output_schema: null
36
- },
37
- choice: {
38
- label: "Multiple Choice",
39
- shorthand: "Multi",
40
- icon: "CheckSquare",
41
- color: "#DBEAFE",
42
- description: "Single or multiple choice questions with predefined options",
43
- category: "question",
44
- sort_order: 1,
45
- has_options: true,
46
- min_options: 1,
47
- max_option_length: 36,
48
- is_terminal: false,
49
- show_nav_bar: true,
50
- loading: "eager",
51
- supports_prompt: true,
52
- supports_media: true,
53
- is_system: false,
54
- is_active: true,
55
- default_config: {
56
- selection_mode: "single",
57
- choice: { enable_branching: true, record_scores: false },
58
- randomise_options: false,
59
- show_option_count: false,
60
- options: [
61
- { content: "Option 1" },
62
- { content: "Option 2" }
63
- ]
64
- },
65
- config_schema: {
66
- type: "object",
67
- properties: {
68
- choice: {
69
- type: "object",
70
- properties: {
71
- enable_branching: {
72
- type: "boolean",
73
- label: "Enable branching logic",
74
- default: true,
75
- description: "Allow each option to have its own logic path. When disabled, all options share a single jump action."
76
- },
77
- show_answer_feedback: {
78
- type: "boolean",
79
- label: "Show answer feedback",
80
- default: false,
81
- description: "Show correct/incorrect feedback after selection (requires scored options)."
82
- },
83
- record_scores: {
84
- type: "boolean",
85
- label: "Mark correct answer",
86
- default: false,
87
- description: "Select which option is the correct answer."
88
- }
89
- }
90
- },
91
- selection_mode: {
92
- enum: ["single", "multiple"],
93
- type: "string",
94
- label: "Selection mode",
95
- default: "single",
96
- description: "Allow single or multiple selections"
97
- },
98
- allow_text_response: {
99
- type: "boolean",
100
- label: "Allow text response",
101
- default: false,
102
- description: "Allow free-text response in addition to options (single choice only)"
103
- },
104
- randomise_options: {
105
- type: "boolean",
106
- label: "Randomise options",
107
- default: false,
108
- description: "Show options in random order"
109
- },
110
- show_option_count: {
111
- type: "boolean",
112
- label: "Show option count",
113
- default: false,
114
- description: "Display number of options"
115
- },
116
- content_media_type: {
117
- enum: ["upload", "recorded"],
118
- type: "string",
119
- label: "Content media type",
120
- description: "How the question media was provided"
121
- }
122
- }
123
- },
124
- response_schema: {
125
- type: "string",
126
- description: "Selected option ID"
127
- },
128
- output_schema: {
129
- type: "object",
130
- required: ["type", "label", "option_id"],
131
- properties: {
132
- type: { type: "string", const: "choice" },
133
- label: { type: "string", description: "Human-readable option label" },
134
- option_id: { type: "string", format: "uuid", description: "Selected option UUID" }
135
- }
136
- }
137
- },
138
- open: {
139
- label: "Open Ended",
140
- shorthand: "Open",
141
- icon: "Type",
142
- color: "#DBEAFE",
143
- description: "Free-form text responses from users",
144
- category: "question",
145
- sort_order: 2,
146
- has_options: false,
147
- is_terminal: false,
148
- show_nav_bar: true,
149
- loading: "eager",
150
- supports_prompt: true,
151
- supports_media: true,
152
- is_system: false,
153
- is_active: true,
154
- default_config: {
155
- formats: [
156
- { order: 0, format: "text" },
157
- { order: 1, format: "audio" },
158
- { order: 2, format: "video" }
159
- ]
160
- },
161
- config_schema: {
162
- type: "object",
163
- properties: {
164
- formats: {
165
- type: "array",
166
- items: {
167
- type: "object",
168
- properties: {
169
- format: { enum: ["text", "audio", "video"], type: "string" },
170
- order: { type: "number" }
171
- }
172
- },
173
- label: "Allowed response formats",
174
- description: "Which formats the user can respond with, in display order"
175
- },
176
- content_media_type: {
177
- enum: ["upload", "recorded"],
178
- type: "string",
179
- label: "Content media type",
180
- description: "How the question media was provided"
181
- }
182
- }
183
- },
184
- response_schema: {
185
- type: "object",
186
- required: ["response_type"],
187
- properties: {
188
- text: {
189
- type: "string",
190
- description: "Text response (when response_type is text) or transcribed text from audio/video"
191
- },
192
- media: {
193
- type: "object",
194
- properties: {
195
- type: { enum: ["audio", "video"], type: "string" },
196
- storage_path: { type: "string" }
197
- },
198
- description: "Media response (when response_type is audio or video)"
199
- },
200
- response_type: {
201
- enum: ["text", "audio", "video"],
202
- type: "string"
203
- },
204
- transcription: {
205
- type: "object",
206
- properties: {
207
- text: { type: "string" },
208
- language: { type: "string" },
209
- duration: { type: "number" },
210
- words: { type: "array" }
211
- },
212
- description: "Whisper transcription data for audio/video responses"
213
- }
214
- }
215
- },
216
- output_schema: {
217
- type: "object",
218
- required: ["type"],
219
- properties: {
220
- type: { type: "string", const: "text" },
221
- text: { type: "string", description: "Text response or transcription" },
222
- media: {
223
- type: "object",
224
- properties: {
225
- type: { enum: ["audio", "video"], type: "string" },
226
- storage_path: { type: "string" }
227
- }
228
- },
229
- transcription: {
230
- type: "object",
231
- properties: {
232
- text: { type: "string" },
233
- language: { type: "string" }
234
- }
235
- }
236
- }
237
- }
238
- },
239
- scale: {
240
- label: "Scale",
241
- shorthand: "Scale",
242
- icon: "BarChart3",
243
- color: "#DBEAFE",
244
- description: "Numerical rating or scale questions (1-10, etc.)",
245
- category: "question",
246
- sort_order: 3,
247
- has_options: false,
248
- is_terminal: false,
249
- show_nav_bar: true,
250
- loading: "eager",
251
- supports_prompt: true,
252
- supports_media: true,
253
- is_system: false,
254
- is_active: false,
255
- default_config: null,
256
- config_schema: {
257
- type: "object",
258
- properties: {
259
- max: { type: "number", label: "Maximum value", default: 10, required: true },
260
- min: { type: "number", label: "Minimum value", default: 1, required: true },
261
- step: { min: 0.1, type: "number", label: "Step increment", default: 1 },
262
- left_label: { type: "string", label: "Left label", placeholder: "Not likely" },
263
- right_label: { type: "string", label: "Right label", placeholder: "Very likely" },
264
- show_numbers: { type: "boolean", label: "Show numbers", default: true }
265
- }
266
- },
267
- response_schema: {
268
- type: "number",
269
- description: "Selected scale value"
270
- },
271
- output_schema: null
272
- },
273
- booking: {
274
- label: "Booking",
275
- shorthand: "Booking",
276
- icon: "Calendar",
277
- color: "#E9D5FF",
278
- description: null,
279
- category: "action",
280
- sort_order: 4,
281
- has_options: false,
282
- is_terminal: false,
283
- show_nav_bar: true,
284
- loading: "lazy",
285
- supports_prompt: false,
286
- supports_media: false,
287
- is_system: false,
288
- is_active: false,
289
- default_config: null,
290
- config_schema: {
291
- type: "object",
292
- oneOf: [
293
- {
294
- required: ["calendly"],
295
- properties: {
296
- calendly: {
297
- type: "object",
298
- properties: {
299
- event_name: { type: "string", label: "Event name", description: "Display name for the event" },
300
- event_type_uri: { type: "string", label: "Event type URI", format: "uri", description: "Your Calendly event type URI", placeholder: "https://calendly.com/your-link" }
301
- }
302
- }
303
- }
304
- },
305
- {
306
- required: ["google_meet"],
307
- properties: {
308
- google_meet: {
309
- type: "object",
310
- properties: {
311
- duration: { type: "number", label: "Duration (minutes)", default: 30, description: "Meeting duration in minutes" },
312
- calendar_id: { type: "string", label: "Calendar", description: "Google Calendar to use for bookings" },
313
- event_title: { type: "string", label: "Event title", default: "Meeting", description: "Default title for scheduled meetings" },
314
- calendar_name: { type: "string", label: "Calendar name", description: "Display name for the selected calendar" }
315
- }
316
- }
317
- }
318
- }
319
- ],
320
- description: "Booking provider configuration (one provider per question)"
321
- },
322
- response_schema: {
323
- type: "object",
324
- oneOf: [
325
- {
326
- required: ["calendly"],
327
- properties: {
328
- calendly: {
329
- type: "object",
330
- required: ["event_uri", "scheduled_time"],
331
- properties: {
332
- event_uri: { type: "string", format: "uri" },
333
- event_type: { type: "string" },
334
- invitee_uri: { type: "string", format: "uri" },
335
- reschedule_url: { type: "string", format: "uri" },
336
- scheduled_time: { type: "string", format: "date-time" },
337
- cancellation_url: { type: "string", format: "uri" }
338
- }
339
- }
340
- }
341
- }
342
- ],
343
- description: "Booking response with provider-specific data"
344
- },
345
- output_schema: null
346
- },
347
- contact: {
348
- label: "Contact Form",
349
- shorthand: "Contact",
350
- icon: "User",
351
- color: "#FEF9C3",
352
- description: "Collect standardized contact information (name, email, phone, company)",
353
- category: "input",
354
- sort_order: 5,
355
- has_options: false,
356
- is_terminal: false,
357
- show_nav_bar: true,
358
- loading: "lazy",
359
- supports_prompt: false,
360
- supports_media: false,
361
- is_system: false,
362
- is_active: true,
363
- default_config: {
364
- contact: {
365
- fields: [
366
- { id: "first_name", type: "first_name", label: "First Name", enabled: true, required: true },
367
- { id: "email", type: "email", label: "Email", enabled: true, required: true }
368
- ],
369
- prompt: ""
370
- },
371
- consent_items: [
372
- { id: "default-consent", label: "I agree to the privacy policy and terms of service", order: 0, required: true }
373
- ]
374
- },
375
- config_schema: {
376
- type: "object",
377
- properties: {
378
- title: { type: "string" },
379
- fields: {
380
- type: "array",
381
- items: {
382
- type: "object",
383
- required: ["id", "required"],
384
- properties: {
385
- id: { type: "string" },
386
- type: { enum: ["text", "textarea", "email", "tel", "url"], type: "string" },
387
- label: { type: "string" },
388
- order: { type: "number" },
389
- required: { type: "boolean", default: true },
390
- is_custom: { type: "boolean", default: false }
391
- }
392
- }
393
- },
394
- description: { type: "string" },
395
- consent_items: {
396
- type: "array",
397
- items: {
398
- type: "object",
399
- properties: {
400
- id: { type: "string" },
401
- label: { type: "string" },
402
- order: { type: "number" },
403
- required: { type: "boolean" }
404
- }
405
- }
406
- }
407
- }
408
- },
409
- response_schema: {
410
- type: "object",
411
- required: ["fields"],
412
- properties: {
413
- fields: {
414
- type: "array",
415
- items: {
416
- type: "object",
417
- required: ["id", "type", "label", "value"],
418
- properties: {
419
- id: { type: "string" },
420
- type: { type: "string" },
421
- label: { type: "string" },
422
- value: { type: "string" }
423
- }
424
- },
425
- description: "Contact form field entries with metadata"
426
- },
427
- consent: {
428
- type: "array",
429
- items: {
430
- type: "object",
431
- required: ["id", "label", "accepted"],
432
- properties: {
433
- id: { type: "string" },
434
- label: { type: "string" },
435
- accepted: { type: "boolean" }
436
- }
437
- },
438
- description: "Consent checkbox entries"
439
- }
440
- }
441
- },
442
- output_schema: {
443
- type: "object",
444
- required: ["type", "fields"],
445
- properties: {
446
- type: { type: "string", const: "contact" },
447
- fields: {
448
- type: "array",
449
- items: {
450
- type: "object",
451
- required: ["type", "value"],
452
- properties: {
453
- type: { type: "string" },
454
- value: { type: "string" }
455
- }
456
- }
457
- }
458
- }
459
- }
460
- },
461
- file_upload: {
462
- label: "File Upload",
463
- shorthand: "Upload",
464
- icon: "Upload",
465
- color: "#FEF9C3",
466
- description: "Collect file uploads from respondents",
467
- category: "input",
468
- sort_order: 6,
469
- has_options: false,
470
- is_terminal: false,
471
- show_nav_bar: true,
472
- loading: "lazy",
473
- supports_prompt: false,
474
- supports_media: false,
475
- is_system: false,
476
- is_active: false,
477
- default_config: null,
478
- config_schema: {
479
- type: "object",
480
- properties: {
481
- max_files: { max: 20, min: 1, type: "number", label: "Max files", default: 5, description: "Maximum number of files respondents can upload" },
482
- max_file_size_mb: { max: 500, min: 1, type: "number", label: "Max file size (MB)", default: 50, description: "Maximum size per file (1-500 MB)" },
483
- allowed_media_types: {
484
- type: "array",
485
- items: { enum: ["images", "videos", "documents", "all"], type: "string" },
486
- label: "Allowed media types",
487
- default: ["images", "documents"],
488
- enumLabels: {
489
- all: "All file types",
490
- images: "Images (JPEG, PNG, GIF, WebP, HEIC)",
491
- videos: "Videos (MP4, MOV, WebM, AVI)",
492
- documents: "Documents (PDF, DOC, DOCX, XLS, XLSX, TXT, CSV)"
493
- },
494
- description: "Select which types of files respondents can upload"
495
- }
496
- }
497
- },
498
- response_schema: {
499
- type: "object",
500
- required: ["files"],
501
- properties: {
502
- files: {
503
- type: "array",
504
- items: {
505
- type: "object",
506
- required: ["id", "name", "storage_path", "url", "mime_type", "size", "uploaded_at"],
507
- properties: {
508
- id: { type: "string", format: "uuid", description: "Unique file identifier" },
509
- url: { type: "string", format: "uri", description: "Signed Supabase Storage URL for download" },
510
- name: { type: "string", description: "Original filename" },
511
- size: { type: "integer", minimum: 1, description: "File size in bytes" },
512
- mime_type: { type: "string", description: "File MIME type" },
513
- uploaded_at: { type: "string", format: "date-time", description: "ISO timestamp of upload completion" },
514
- storage_path: { type: "string", description: "Full storage path: workspace_id/form_id/question_id/uuid.ext" }
515
- }
516
- },
517
- minItems: 1,
518
- description: "Array of uploaded file metadata"
519
- }
520
- }
521
- },
522
- output_schema: null
523
- },
524
- payment: {
525
- label: "Payment",
526
- shorthand: "Payment",
527
- icon: "DollarSign",
528
- color: "#E9D5FF",
529
- description: null,
530
- category: "action",
531
- sort_order: 7,
532
- has_options: false,
533
- is_terminal: false,
534
- show_nav_bar: true,
535
- loading: "lazy",
536
- supports_prompt: false,
537
- supports_media: false,
538
- is_system: false,
539
- is_active: false,
540
- default_config: { amount: null, currency: "usd", provider: "stripe" },
541
- config_schema: {
542
- type: "object",
543
- required: ["amount", "currency", "provider"],
544
- properties: {
545
- amount: { type: "number", label: "Amount (in cents)", minimum: 50, required: true, description: "Payment amount in cents (e.g., 5000 = $50.00)" },
546
- currency: { enum: ["usd", "eur", "gbp", "cad", "aud"], type: "string", label: "Currency", default: "usd", required: true, description: "Three-letter ISO currency code" },
547
- provider: { enum: ["stripe", "paddle"], type: "string", label: "Payment Provider", default: "stripe", required: true, description: "Payment provider to use" },
548
- workspace_integration_id: { type: "string", label: "Workspace Integration ID", description: "UUID of the workspace_integrations record for the connected payment account" }
549
- },
550
- description: "Payment configuration with provider-agnostic flat structure"
551
- },
552
- response_schema: {
553
- type: "object",
554
- oneOf: [
555
- {
556
- required: ["stripe"],
557
- properties: {
558
- stripe: {
559
- type: "object",
560
- required: ["payment_intent_id", "status", "amount", "currency"],
561
- properties: {
562
- name: { type: "string", description: "Customer name from Stripe billing details" },
563
- email: { type: "string", format: "email", description: "Customer email from Stripe billing details" },
564
- amount: { type: "number", description: "Payment amount in cents" },
565
- status: { enum: ["pending", "succeeded", "failed"], type: "string", description: "Payment status" },
566
- currency: { type: "string", description: "Three-letter ISO currency code (e.g., usd, eur)" },
567
- payment_intent_id: { type: "string", description: "Stripe payment intent ID" }
568
- }
569
- }
570
- }
571
- },
572
- {
573
- required: ["square"],
574
- properties: {
575
- square: {
576
- type: "object",
577
- properties: {
578
- amount: { type: "number" },
579
- status: { type: "string" },
580
- order_id: { type: "string" },
581
- payment_id: { type: "string" }
582
- }
583
- }
584
- }
585
- },
586
- {
587
- required: ["paddle"],
588
- properties: {
589
- paddle: {
590
- type: "object",
591
- properties: {
592
- amount: { type: "number" },
593
- status: { type: "string" },
594
- order_id: { type: "string" },
595
- checkout_id: { type: "string" }
596
- }
597
- }
598
- }
599
- }
600
- ],
601
- description: "Payment response with provider-specific data"
602
- },
603
- output_schema: null
604
- },
605
- binary: {
606
- label: "Binary",
607
- shorthand: "A/B",
608
- icon: "CircleCheck",
609
- color: "#D1FAE5",
610
- description: "Two-option choice - yes/no, true/false, or this vs that",
611
- category: "question",
612
- sort_order: 1.5,
613
- has_options: true,
614
- min_options: 2,
615
- max_options: 2,
616
- max_option_length: 12,
617
- is_terminal: false,
618
- show_nav_bar: true,
619
- loading: "eager",
620
- supports_prompt: true,
621
- supports_media: true,
622
- is_system: false,
623
- is_active: false,
624
- default_config: {
625
- choice: { enable_branching: true },
626
- options: [
627
- { content: "Yes" },
628
- { content: "No" }
629
- ]
630
- },
631
- config_schema: {
632
- type: "object",
633
- properties: {
634
- choice: {
635
- type: "object",
636
- properties: {
637
- enable_branching: {
638
- type: "boolean",
639
- label: "Enable branching logic",
640
- default: true,
641
- description: "Allow each option to have its own logic path."
642
- }
643
- }
644
- },
645
- content_media_type: {
646
- enum: ["upload", "recorded"],
647
- type: "string",
648
- label: "Content media type",
649
- description: "How the question media was provided"
650
- }
651
- }
652
- },
653
- response_schema: {
654
- type: "string",
655
- description: "Selected option ID"
656
- },
657
- output_schema: {
658
- type: "object",
659
- required: ["type", "label", "option_id"],
660
- properties: {
661
- type: { type: "string", const: "binary" },
662
- label: { type: "string", description: "Human-readable option label" },
663
- option_id: { type: "string", format: "uuid", description: "Selected option UUID" }
664
- }
665
- }
666
- },
667
- button: {
668
- label: "Button",
669
- shorthand: "Button",
670
- icon: "RectangleHorizontal",
671
- color: "#DBEAFE",
672
- description: "Simple button for acknowledgment or navigation",
673
- category: "question",
674
- sort_order: 3,
675
- has_options: true,
676
- min_options: 1,
677
- max_options: 1,
678
- max_option_length: 28,
679
- is_terminal: false,
680
- show_nav_bar: true,
681
- loading: "eager",
682
- supports_prompt: true,
683
- supports_media: true,
684
- is_system: false,
685
- is_active: true,
686
- default_config: { options: [{ content: "Continue" }] },
687
- config_schema: {
688
- type: "object",
689
- properties: {
690
- button_text: { type: "string", label: "Button text", default: "Continue" },
691
- button_style: { enum: ["primary", "secondary", "outline"], type: "string", label: "Button style", default: "primary" }
692
- }
693
- },
694
- response_schema: {
695
- type: "string",
696
- description: "Selected option ID"
697
- },
698
- output_schema: {
699
- type: "object",
700
- required: ["type", "label", "option_id"],
701
- properties: {
702
- type: { type: "string", const: "button" },
703
- label: { type: "string", description: "Human-readable option label" },
704
- option_id: { type: "string", format: "uuid", description: "Selected option UUID" }
705
- }
706
- }
707
- },
708
- redirect: {
709
- label: "Redirect",
710
- shorthand: "Redirect",
711
- icon: "ExternalLink",
712
- color: "#FEE2E2",
713
- description: "Redirect users to an external URL",
714
- category: "end",
715
- sort_order: 101,
716
- has_options: false,
717
- is_terminal: true,
718
- show_nav_bar: false,
719
- loading: "lazy",
720
- supports_prompt: false,
721
- supports_media: false,
722
- is_system: false,
723
- is_active: true,
724
- default_config: { url: "", auto_redirect: true },
725
- config_schema: {
726
- type: "object",
727
- properties: {
728
- url: { type: "string", label: "Redirect URL", placeholder: "https://example.com" },
729
- auto_redirect: { type: "boolean", label: "Auto-redirect", default: true, description: "Automatically redirect to the URL instead of showing a button" }
730
- }
731
- },
732
- response_schema: {
733
- type: "string",
734
- description: "URL the user was redirected to"
735
- },
736
- output_schema: null
737
- },
738
- link_list: {
739
- label: "Link List",
740
- shorthand: "Links",
741
- icon: "ExternalLink",
742
- color: "#FEE2E2",
743
- description: "Display a list of links for users to choose from",
744
- category: "end",
745
- sort_order: 102,
746
- has_options: false,
747
- is_terminal: true,
748
- show_nav_bar: false,
749
- loading: "lazy",
750
- supports_prompt: false,
751
- supports_media: false,
752
- is_system: false,
753
- is_active: false,
754
- default_config: { links: [{ id: "default", url: "", title: "", description: "", order: 0 }], auto_redirect: false },
755
- config_schema: {
756
- type: "object",
757
- properties: {
758
- links: {
759
- type: "array",
760
- items: {
761
- type: "object",
762
- required: ["id", "url"],
763
- properties: {
764
- id: { type: "string" },
765
- url: { type: "string", label: "URL", placeholder: "https://example.com" },
766
- title: { type: "string", label: "Heading" },
767
- description: { type: "string", label: "Description" },
768
- order: { type: "number", label: "Sort order" }
769
- }
770
- },
771
- label: "Links",
772
- minItems: 1
773
- },
774
- auto_redirect: { type: "boolean", label: "Auto-redirect", default: false, description: "Automatically redirect to the URL instead of showing a button" }
775
- }
776
- },
777
- response_schema: {
778
- type: "string",
779
- description: "URL of the link that was clicked"
780
- },
781
- output_schema: null
782
- },
783
- file_download: {
784
- label: "File Download",
785
- shorthand: "Download",
786
- icon: "Download",
787
- color: "#E9D5FF",
788
- description: "Provide a file for respondents to download",
789
- category: "action",
790
- sort_order: 103,
791
- has_options: false,
792
- is_terminal: false,
793
- show_nav_bar: true,
794
- loading: "lazy",
795
- supports_prompt: false,
796
- supports_media: false,
797
- is_system: false,
798
- is_active: false,
799
- default_config: null,
800
- config_schema: {
801
- type: "object",
802
- properties: {
803
- files: {
804
- type: "array",
805
- items: {
806
- type: "object",
807
- properties: {
808
- file_name: { type: "string", label: "Display file name", required: true },
809
- file_path: { type: "string", label: "File path in storage", required: true },
810
- file_size: { type: "number", label: "File size in bytes" },
811
- mime_type: { type: "string", label: "MIME type" }
812
- }
813
- },
814
- label: "Downloadable files"
815
- },
816
- button_text: { type: "string", label: "Button text", default: "Download Files" },
817
- description: { type: "string", label: "Description text" }
818
- }
819
- },
820
- response_schema: {
821
- type: "object",
822
- required: ["files"],
823
- properties: {
824
- files: {
825
- type: "array",
826
- items: {
827
- type: "object",
828
- required: ["file_path", "file_name", "downloaded_at"],
829
- properties: {
830
- file_name: { type: "string", description: "Display name of the file" },
831
- file_path: { type: "string", description: "Storage path of the downloaded file" },
832
- downloaded_at: { type: "string", format: "date-time", description: "Timestamp when download was initiated" }
833
- }
834
- },
835
- description: "List of files downloaded by respondent"
836
- }
837
- }
838
- },
839
- output_schema: null
840
- },
841
- shopping: {
842
- label: "Shopping",
843
- shorthand: "Shop",
844
- icon: "ShoppingBag",
845
- color: "#D1FAE5",
846
- description: "Product recommendations with direct checkout",
847
- category: "action",
848
- sort_order: 8,
849
- has_options: false,
850
- is_terminal: true,
851
- show_nav_bar: true,
852
- loading: "lazy",
853
- supports_prompt: false,
854
- supports_media: false,
855
- is_system: false,
856
- is_active: true,
857
- default_config: { provider: "shopify", cta_mode: "checkout", source_mode: "manual" },
858
- config_schema: {
859
- type: "object",
860
- required: ["provider", "cta_mode"],
861
- properties: {
862
- title: { type: "string", label: "Heading", description: "Heading shown above the product list" },
863
- description: { type: "string", label: "Description", description: "Body text shown below the heading" },
864
- provider: { enum: ["shopify"], type: "string", label: "Provider", default: "shopify", description: "Ecommerce provider" },
865
- workspace_integration_id: { type: "string", label: "Workspace Integration ID", description: "UUID of the workspace_integrations record for the connected store" },
866
- cta_mode: { enum: ["checkout", "add_to_cart"], type: "string", label: "CTA Mode", default: "checkout", description: "Whether to go to checkout or add to cart" },
867
- source_mode: { enum: ["manual", "collection"], type: "string", label: "Product source", default: "manual", description: "How products are selected: manually picked or from a collection" },
868
- collection_id: { type: "string", label: "Collection ID", description: "Shopify collection GID (when source_mode is collection)" },
869
- collection_title: { type: "string", label: "Collection title", description: "Display name of the selected collection" },
870
- products: {
871
- type: "array",
872
- label: "Selected Products",
873
- description: "Products to show (static mode)",
874
- items: {
875
- type: "object",
876
- properties: {
877
- product_id: { type: "string", description: "Shopify product GID" },
878
- variant_id: { type: "string", description: "Shopify variant GID" },
879
- title: { type: "string" },
880
- handle: { type: "string" },
881
- image_url: { type: "string" },
882
- price: { type: "string" },
883
- currency: { type: "string" },
884
- recommended: { type: "boolean", default: true, description: "Pre-ticked in viewer" }
885
- }
886
- }
887
- },
888
- product_mappings: {
889
- type: "array",
890
- label: "Score-based product mappings",
891
- description: "Map score ranges to different product sets. First matching range wins.",
892
- items: {
893
- type: "object",
894
- required: ["min", "max"],
895
- properties: {
896
- min: { type: "integer", label: "Minimum score (inclusive)" },
897
- max: { type: "integer", label: "Maximum score (inclusive)" },
898
- message: { type: "string", label: "Message" },
899
- products: { type: "array", description: "Products for this score range" }
900
- }
901
- }
902
- }
903
- },
904
- description: "Shopping configuration with provider-agnostic structure"
905
- },
906
- response_schema: null,
907
- output_schema: null
908
- },
909
- end_screen: {
910
- label: "End Screen",
911
- shorthand: "End",
912
- icon: "Flag",
913
- color: "#FEE2E2",
914
- description: "Final screen shown when form is completed",
915
- category: "end",
916
- sort_order: 999,
917
- has_options: false,
918
- is_terminal: true,
919
- show_nav_bar: false,
920
- loading: "eager",
921
- supports_prompt: false,
922
- supports_media: true,
923
- is_system: false,
924
- is_active: true,
925
- default_config: null,
926
- config_schema: {
927
- type: "object",
928
- properties: {
929
- title: { type: "string", label: "Title", default: "Thank you!", description: "Heading shown on completion" },
930
- message: { type: "string", label: "Message", default: "Your response has been submitted.", description: "Message shown on completion" },
931
- show_score: { type: "boolean", label: "Show score", default: false, description: 'Display score on the end screen (e.g. "You scored 4 out of 5")' },
932
- icon: { type: "string", label: "Icon", enum: ["tick", "trophy", "star", "crown", "party", "none"], default: "tick", description: "Icon shown above the title" },
933
- show_share_button: { type: "boolean", label: "Show share button", default: false, description: "Show a share button above the CTA" },
934
- cta_type: { type: "string", label: "CTA type", enum: ["none", "restart", "external_link"], default: "none", description: "Primary call-to-action button type" },
935
- cta_text: { type: "string", label: "CTA button text", default: "Continue", description: "Button label for the CTA" },
936
- cta_url: { type: "string", label: "CTA URL", format: "uri", description: "URL to open (only for external_link CTA type)", placeholder: "https://example.com" },
937
- score_ranges: {
938
- type: "array",
939
- label: "Score-based content",
940
- description: "Show different content based on cumulative score. First matching range wins.",
941
- items: {
942
- type: "object",
943
- required: ["min", "max", "title"],
944
- properties: {
945
- min: { type: "integer", label: "Minimum score (inclusive)" },
946
- max: { type: "integer", label: "Maximum score (inclusive)" },
947
- title: { type: "string", label: "Title" },
948
- message: { type: "string", label: "Message" }
949
- }
950
- }
951
- },
952
- scoring_results: {
953
- type: "array",
954
- label: "Category-based results",
955
- description: "Results keyed by scoring category. The winning category (highest non-knocked-out score) determines which result is shown.",
956
- items: {
957
- type: "object",
958
- required: ["category", "title"],
959
- properties: {
960
- category: { type: "string", label: "Category key (must match keys used in option scores)" },
961
- title: { type: "string", label: "Title" },
962
- message: { type: "string", label: "Message" },
963
- cta_url: { type: "string", label: "CTA URL", format: "uri" },
964
- cta_text: { type: "string", label: "CTA button text" }
965
- }
966
- }
967
- }
968
- }
969
- },
970
- response_schema: null,
971
- output_schema: null
972
- }
973
- };
974
- var NODE_TYPES_LIST = Object.entries(NODE_TYPES).map(([type, def]) => ({ type, ...def })).sort((a, b) => a.sort_order - b.sort_order);
975
- var NODE_TYPE_KEYS = Object.keys(NODE_TYPES);
976
- var RESPONSE_SCHEMAS = Object.fromEntries(
977
- Object.entries(NODE_TYPES).map(([k, v]) => [k, v.response_schema])
978
- );
979
- var CONFIG_SCHEMAS = Object.fromEntries(
980
- Object.entries(NODE_TYPES).map(([k, v]) => [k, v.config_schema])
981
- );
982
- var OUTPUT_SCHEMAS = Object.fromEntries(
983
- Object.entries(NODE_TYPES).map(([k, v]) => [k, v.output_schema])
984
- );
985
- var NODE_TYPE_METADATA = Object.fromEntries(
986
- Object.entries(NODE_TYPES).map(([k, v]) => [k, {
987
- supports_prompt: v.supports_prompt,
988
- supports_media: v.supports_media,
989
- is_terminal: v.is_terminal,
990
- show_nav_bar: v.show_nav_bar,
991
- category: v.category,
992
- loading: v.loading
993
- }])
994
- );
995
- var TERMINAL_TYPES = Object.entries(NODE_TYPES).filter(([, v]) => v.is_terminal).map(([k]) => k);
996
- var NON_COUNTABLE_TYPES = Object.entries(NODE_TYPES).filter(([, v]) => v.is_system || v.is_terminal).map(([k]) => k);
997
- var NON_COUNTABLE_FILTER = `(${NON_COUNTABLE_TYPES.map((t) => `"${t}"`).join(",")})`;
998
-
999
- // ../config/integration-catalog.js
1000
- var INTEGRATION_CATALOG = {
1001
- stripe: {
1002
- name: "Stripe",
1003
- category: "form_action",
1004
- description: "Accept payments directly in your forms",
1005
- logo_url: "https://cdn.brandfetch.io/stripe.com/w/400/h/400",
1006
- color: "#635BFF",
1007
- sort_order: 1,
1008
- tags: ["payment"],
1009
- external_account_id_field: "account_id",
1010
- display_name_field: "account_name",
1011
- is_active: true
1012
- },
1013
- paddle: {
1014
- name: "Paddle",
1015
- category: "form_action",
1016
- description: "Accept payments with Paddle",
1017
- logo_url: "https://cdn.brandfetch.io/paddle.com/w/400/h/400",
1018
- color: "#6837FC",
1019
- sort_order: 2,
1020
- tags: ["payment"],
1021
- external_account_id_field: null,
1022
- display_name_field: null,
1023
- is_active: true
1024
- },
1025
- square: {
1026
- name: "Square",
1027
- category: "form_action",
1028
- description: "Accept payments with Square",
1029
- logo_url: "https://cdn.brandfetch.io/square.com/w/400/h/400",
1030
- color: "#000000",
1031
- sort_order: 3,
1032
- tags: ["payment"],
1033
- external_account_id_field: null,
1034
- display_name_field: null,
1035
- is_active: true
1036
- },
1037
- calendly: {
1038
- name: "Calendly",
1039
- category: "form_action",
1040
- description: "Schedule meetings directly in your forms",
1041
- logo_url: "https://cdn.brandfetch.io/calendly.com/w/400/h/400",
1042
- color: "#006BFF",
1043
- sort_order: 4,
1044
- tags: ["booking"],
1045
- external_account_id_field: "user_uri",
1046
- display_name_field: "user_name",
1047
- is_active: true
1048
- },
1049
- calcom: {
1050
- name: "Cal.com",
1051
- category: "form_action",
1052
- description: "Open-source scheduling platform for booking meetings",
1053
- logo_url: null,
1054
- color: "#292929",
1055
- sort_order: 4,
1056
- tags: ["booking"],
1057
- external_account_id_field: "id",
1058
- display_name_field: "name",
1059
- is_active: true
1060
- },
1061
- docusign: {
1062
- name: "DocuSign",
1063
- category: "form_action",
1064
- description: "Collect signatures in your forms",
1065
- logo_url: "https://cdn.brandfetch.io/docusign.com/w/400/h/400",
1066
- color: "#FF0037",
1067
- sort_order: 5,
1068
- tags: ["document", "e-signature", "contract"],
1069
- external_account_id_field: null,
1070
- display_name_field: null,
1071
- is_active: true
1072
- },
1073
- webhook: {
1074
- name: "Webhook",
1075
- category: "automation",
1076
- description: "Send responses to custom webhooks",
1077
- logo_url: null,
1078
- color: "#6B7280",
1079
- sort_order: 100,
1080
- tags: ["webhook", "automation", "developer"],
1081
- external_account_id_field: null,
1082
- display_name_field: null,
1083
- is_active: true
1084
- },
1085
- zapier: {
1086
- name: "Zapier",
1087
- category: "automation",
1088
- description: "Connect to 5,000+ apps and automate workflows",
1089
- logo_url: "https://cdn.brandfetch.io/zapier.com/w/400/h/400",
1090
- color: "#FF4A00",
1091
- sort_order: 101,
1092
- tags: ["automation", "workflow", "no-code"],
1093
- external_account_id_field: null,
1094
- display_name_field: null,
1095
- is_active: true
1096
- },
1097
- make: {
1098
- name: "Make",
1099
- category: "automation",
1100
- description: "Advanced automation and integrations",
1101
- logo_url: "https://cdn.brandfetch.io/make.com/w/400/h/400",
1102
- color: "#6B4AFF",
1103
- sort_order: 102,
1104
- tags: ["automation", "workflow", "integration"],
1105
- external_account_id_field: null,
1106
- display_name_field: null,
1107
- is_active: true
1108
- },
1109
- hubspot: {
1110
- name: "HubSpot",
1111
- category: "automation",
1112
- description: "Sync responses to HubSpot CRM",
1113
- logo_url: "https://cdn.brandfetch.io/hubspot.com/w/400/h/400",
1114
- color: "#FF7A59",
1115
- sort_order: 103,
1116
- tags: ["automation", "crm", "marketing"],
1117
- external_account_id_field: "portal_id",
1118
- display_name_field: "portal_name",
1119
- is_active: true
1120
- },
1121
- salesforce: {
1122
- name: "Salesforce",
1123
- category: "automation",
1124
- description: "Sync responses to Salesforce CRM",
1125
- logo_url: "https://cdn.brandfetch.io/salesforce.com/w/400/h/400",
1126
- color: "#00A1E0",
1127
- sort_order: 104,
1128
- tags: ["automation", "crm", "sales"],
1129
- external_account_id_field: null,
1130
- display_name_field: null,
1131
- is_active: true
1132
- },
1133
- slack: {
1134
- name: "Slack",
1135
- category: "automation",
1136
- description: "Send notifications to Slack channels",
1137
- logo_url: "https://cdn.brandfetch.io/slack.com/w/400/h/400",
1138
- color: "#4A154B",
1139
- sort_order: 105,
1140
- tags: ["automation", "communication", "notifications"],
1141
- external_account_id_field: null,
1142
- display_name_field: null,
1143
- is_active: true
1144
- },
1145
- shopify: {
1146
- name: "Shopify",
1147
- category: "ecommerce",
1148
- description: "Product recommendations from quiz results with direct checkout",
1149
- logo_url: "https://cdn.brandfetch.io/shopify.com/w/400/h/400",
1150
- color: "#95BF47",
1151
- sort_order: 200,
1152
- tags: ["ecommerce", "product", "checkout", "quiz"],
1153
- external_account_id_field: "shop_domain",
1154
- display_name_field: "shop_name",
1155
- is_active: true
1156
- }
1157
- };
1158
- var INTEGRATION_CATALOG_LIST = Object.entries(INTEGRATION_CATALOG).map(([slug, def]) => ({ slug, ...def })).sort((a, b) => a.sort_order - b.sort_order);
1159
- var INTEGRATION_SLUGS = Object.keys(INTEGRATION_CATALOG);
1160
-
1161
- // ../config/index.js
1162
- var isServer = typeof window === "undefined";
1163
- var isBrowser = typeof window !== "undefined";
1164
- var isLocalhost = isBrowser && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" || window.location.hostname.includes("local"));
1165
- var isDevelopment = isServer ? process.env.NODE_ENV !== "production" : isLocalhost;
1166
- var ENV = {
1167
- isDevelopment,
1168
- isProduction: !isDevelopment,
1169
- isLocalhost: isDevelopment
1170
- // alias for clarity
1171
- };
1172
- var COOKIE_DOMAIN = ENV.isDevelopment ? void 0 : ".clipform.io";
1173
- function getUrls() {
1174
- if (ENV.isDevelopment) {
1175
- return {
1176
- marketing: "http://localhost:3002",
1177
- dashboard: "http://localhost:3000",
1178
- viewer: "http://localhost:3001",
1179
- api: "http://localhost:3003",
1180
- // No separate MCP host in dev - tools live on the api subpath.
1181
- mcp: "http://localhost:3003/mcp",
1182
- docs: "http://localhost:3004"
1183
- };
1184
- }
1185
- return {
1186
- marketing: "https://www.clipform.io",
1187
- dashboard: "https://app.clipform.io",
1188
- viewer: "https://clipform.io",
1189
- api: "https://api.clipform.io",
1190
- // Dedicated subdomain for the remote MCP server. Same Render service
1191
- // as `api`, just routed by Host header. Token audience is bound here.
1192
- mcp: "https://mcp.clipform.io",
1193
- docs: "https://docs.clipform.io"
1194
- };
1195
- }
1196
- var BUSINESS = {
1197
- name: "ClipForm",
1198
- tagline: "Video-Driven Form Builder",
1199
- description: "Create engaging forms that combine video content with interactive questions. Capture authentic responses with text, audio, or video recordings.",
1200
- shortDescription: "Interactive video forms that capture authentic responses. Build engaging forms in minutes.",
1201
- domain: "clipform.io",
1202
- email: {
1203
- support: "support@clipform.io",
1204
- sales: "sales@clipform.io",
1205
- hello: "hello@clipform.io"
1206
- },
1207
- phone: "+1 (555) 123-4567",
1208
- urls: getUrls()
1209
- };
1210
- var CONTACT_FIELDS = [
1211
- { id: "first_name", label: "First Name", type: "text", placeholder: "Enter first name", order: 1 },
1212
- { id: "last_name", label: "Last Name", type: "text", placeholder: "Enter last name", order: 2 },
1213
- { id: "email", label: "Email Address", type: "email", placeholder: "you@example.com", order: 3 },
1214
- { id: "phone", label: "Phone Number", type: "tel", placeholder: "(555) 123-4567", order: 4 },
1215
- { id: "company", label: "Company", type: "text", placeholder: "Company name", order: 5 },
1216
- { id: "job_title", label: "Job Title", type: "text", placeholder: "Your role", order: 6 },
1217
- { id: "website", label: "Website", type: "url", placeholder: "https://example.com", order: 7 },
1218
- { id: "linkedin", label: "LinkedIn Profile", type: "url", placeholder: "https://linkedin.com/in/username", order: 8 },
1219
- { id: "message", label: "Message", type: "textarea", placeholder: "Your message...", order: 9 }
1220
- ];
1221
- var CONTACT_FIELDS_MAP = Object.fromEntries(
1222
- CONTACT_FIELDS.map((f) => [f.id, f])
1223
- );
1224
- var API_KEY_PREFIX = ENV.isProduction ? "cf_live_" : "cf_test_";
1225
-
1226
- // src/lib/schemas.ts
1227
- import { z } from "zod";
1228
- var ACTIVE_NODE_TYPES = Object.entries(NODE_TYPES).filter(([, def]) => def.is_active && !def.is_system).map(([type]) => type);
1229
- var CONTACT_FIELD_IDS = CONTACT_FIELDS.map(
1230
- (f) => f.id
1231
- );
1232
- function generateConfigSummary(configSchema, typeKey) {
1233
- if (!configSchema?.properties) return "";
1234
- const parts = [];
1235
- for (const [key, prop] of Object.entries(configSchema.properties)) {
1236
- if (key === "content_media_type") continue;
1237
- if (prop.enum) {
1238
- const vals = prop.enum.map((v) => `"${v}"`).join("|");
1239
- let s = `${key} (${vals}`;
1240
- if (prop.default !== void 0) s += `, default: "${prop.default}"`;
1241
- s += ")";
1242
- parts.push(s);
1243
- } else if (prop.type === "boolean") {
1244
- let s = `${key} (boolean`;
1245
- if (prop.default !== void 0) s += `, default: ${prop.default}`;
1246
- s += ")";
1247
- parts.push(s);
1248
- } else if (prop.type === "number") {
1249
- let s = `${key} (number`;
1250
- if (prop.default !== void 0) s += `, default: ${prop.default}`;
1251
- s += ")";
1252
- parts.push(s);
1253
- } else if (prop.type === "string") {
1254
- let s = `${key} (string`;
1255
- if (prop.default !== void 0) s += `, default: "${prop.default}"`;
1256
- s += ")";
1257
- parts.push(s);
1258
- } else if (prop.type === "array" && prop.items?.properties) {
1259
- const itemKeys = Object.keys(prop.items.properties).join(", ");
1260
- parts.push(`${key} (array of {${itemKeys}})`);
1261
- } else if (prop.type === "array" && prop.items?.enum) {
1262
- const vals = prop.items.enum.map((v) => `"${v}"`).join("|");
1263
- parts.push(`${key} (array of ${vals})`);
1264
- } else if (prop.type === "object" && prop.properties) {
1265
- const childKeys = Object.keys(prop.properties).join(", ");
1266
- parts.push(`${key} ({${childKeys}})`);
1267
- }
1268
- }
1269
- if (typeKey === "contact") {
1270
- parts.push(`Available field IDs: ${CONTACT_FIELD_IDS.join(", ")}`);
1271
- }
1272
- return parts.length > 0 ? `Config: ${parts.join(", ")}` : "";
1273
- }
1274
- var NODE_TYPES_DESCRIPTION = (() => {
1275
- const lines = ["Node types:"];
1276
- for (const type of ACTIVE_NODE_TYPES) {
1277
- const def = NODE_TYPES[type];
1278
- let line = `- ${type}: ${def.description || def.label}`;
1279
- if (def.has_options) {
1280
- line += " (supports options array)";
1281
- }
1282
- const configHint = generateConfigSummary(def.config_schema, type);
1283
- if (configHint) {
1284
- line += `. ${configHint}`;
1285
- }
1286
- lines.push(line);
1287
- }
1288
- return lines.join("\n");
1289
- })();
1290
- var CONFIG_DESCRIPTION = (() => {
1291
- const lines = ["Type-specific configuration. Per-type keys:"];
1292
- for (const type of ACTIVE_NODE_TYPES) {
1293
- const def = NODE_TYPES[type];
1294
- const summary = generateConfigSummary(def.config_schema, type);
1295
- if (summary) {
1296
- lines.push(` ${type}: ${summary}`);
1297
- }
1298
- }
1299
- return lines.join("\n");
1300
- })();
1301
- var OptionSchema = z.object({
1302
- content: z.string().describe("Option text"),
1303
- score: z.number().optional().describe("Score value for this option (for scored quizzes)"),
1304
- scores: z.record(z.number()).optional().describe("Category-based scores keyed by category name (for personality/category quizzes)")
1305
- });
1306
- var OPTIONS_DESCRIPTION = (() => {
1307
- const hints = ["Answer options."];
1308
- for (const type of ACTIVE_NODE_TYPES) {
1309
- const def = NODE_TYPES[type];
1310
- if (!def.has_options) continue;
1311
- const parts = [`${type}: required`];
1312
- if (def.min_options !== void 0) parts.push(`min ${def.min_options}`);
1313
- if (def.max_options !== void 0) parts.push(`max ${def.max_options}`);
1314
- if (def.max_option_length !== void 0) parts.push(`content max ${def.max_option_length} chars`);
1315
- hints.push(parts.join(", "));
1316
- }
1317
- return hints.join(" ");
1318
- })();
1319
- var NodeSchema = z.object({
1320
- type: z.enum(ACTIVE_NODE_TYPES),
1321
- prompt: z.string().describe("The text shown to the respondent"),
1322
- label: z.string().optional().describe("Short label for the node (used in logic builder). Defaults to the prompt text."),
1323
- required: z.boolean().optional().default(true),
1324
- config: z.record(z.unknown()).optional().describe(CONFIG_DESCRIPTION),
1325
- options: z.array(OptionSchema).optional().describe(OPTIONS_DESCRIPTION)
1326
- });
1327
-
1328
- // src/lib/api-client.ts
1329
- var DASHBOARD_URL = process.env.SERVER_URL || process.env.DASHBOARD_URL || "https://api.clipform.io";
1330
- var API_VERSION = "v1";
1331
- var INTERNAL_API_URL = process.env.INTERNAL_API_URL || DASHBOARD_URL;
1332
- var INTERNAL_SECRET = process.env.INTERNAL_SERVICE_SECRET || "";
1333
- var _apiKey;
1334
- function setApiKey(key) {
1335
- _apiKey = key;
1336
- }
1337
- async function callApi(path, options = {}) {
1338
- const { method = "GET", body, params } = options;
1339
- let url = `${DASHBOARD_URL}/${API_VERSION}${path}`;
1340
- if (params) {
1341
- const searchParams = new URLSearchParams(params);
1342
- url += `?${searchParams.toString()}`;
1343
- }
1344
- const headers = {
1345
- "Content-Type": "application/json"
1346
- };
1347
- const mcpAuth = getMcpAuth();
1348
- if (mcpAuth && INTERNAL_SECRET) {
1349
- headers["Authorization"] = `Bearer ${INTERNAL_SECRET}`;
1350
- headers["X-Mcp-User"] = mcpAuth.user_id;
1351
- headers["X-Mcp-Workspace"] = mcpAuth.workspace_id;
1352
- } else if (_apiKey) {
1353
- headers["Authorization"] = `Bearer ${_apiKey}`;
1354
- }
1355
- const fetchOptions = { method, headers };
1356
- if (body && method !== "GET") {
1357
- fetchOptions.body = JSON.stringify(body);
1358
- }
1359
- let response;
1360
- try {
1361
- response = await fetch(url, fetchOptions);
1362
- } catch (err) {
1363
- return {
1364
- ok: false,
1365
- status: 0,
1366
- error: `Failed to connect to dashboard API at ${url}. Make sure the dashboard is running.
1367
-
1368
- Error: ${err instanceof Error ? err.message : String(err)}`
1369
- };
1370
- }
1371
- if (response.status === 204) {
1372
- return { ok: true, data: {} };
1373
- }
1374
- const data = await response.json();
1375
- if (!response.ok) {
1376
- return {
1377
- ok: false,
1378
- status: response.status,
1379
- error: data.error || `API error (${response.status})`
1380
- };
1381
- }
1382
- return { ok: true, data };
1383
- }
1384
- async function callInternalApi(path, options = {}) {
1385
- const { method = "POST", body, params } = options;
1386
- let url = `${INTERNAL_API_URL}${path}`;
1387
- if (params) {
1388
- const searchParams = new URLSearchParams(params);
1389
- url += `?${searchParams.toString()}`;
1390
- }
1391
- const headers = {
1392
- "Content-Type": "application/json"
1393
- };
1394
- if (INTERNAL_SECRET) {
1395
- headers["Authorization"] = `Bearer ${INTERNAL_SECRET}`;
1396
- }
1397
- const fetchOptions = { method, headers };
1398
- if (body && method !== "GET") {
1399
- fetchOptions.body = JSON.stringify(body);
1400
- }
1401
- let response;
1402
- try {
1403
- response = await fetch(url, fetchOptions);
1404
- } catch (err) {
1405
- return {
1406
- ok: false,
1407
- status: 0,
1408
- error: `Failed to connect to internal API at ${url}.
1409
-
1410
- Error: ${err instanceof Error ? err.message : String(err)}`
1411
- };
1412
- }
1413
- if (response.status === 204) {
1414
- return { ok: true, data: {} };
1415
- }
1416
- const data = await response.json();
1417
- if (!response.ok) {
1418
- return {
1419
- ok: false,
1420
- status: response.status,
1421
- error: data.error || `Internal API error (${response.status})`
1422
- };
1423
- }
1424
- return { ok: true, data };
1425
- }
1426
- function errorResult(message) {
1427
- return {
1428
- content: [{ type: "text", text: message }],
1429
- isError: true
1430
- };
1431
- }
1432
- function textResult(text) {
1433
- return {
1434
- content: [{ type: "text", text }]
1435
- };
1436
- }
1437
-
1438
- // src/tools/create-form.ts
1439
- function registerCreateFormTool(server) {
1440
- server.registerTool(
1441
- "clipform_create_form",
1442
- {
1443
- title: "Create Clipform",
1444
- description: `Create a new Clipform (interactive video-style form). Returns a viewer URL and form ID. When connected via an authenticated MCP client (e.g. claude.ai), the form lands directly in the user's workspace. Anonymous sessions get a claim URL to transfer ownership later.
1445
-
1446
- ${NODE_TYPES_DESCRIPTION}
1447
-
1448
- All type definitions and config schemas are derived from @vid-master/config (answer-types). Refer to the config descriptions above for the correct keys and shapes.
1449
-
1450
- Example: A form that asks a question, collects contact info, then finishes:
1451
- {
1452
- title: "Quick Survey",
1453
- questions: [
1454
- { type: "open", prompt: "What's your biggest challenge?" },
1455
- { type: "contact", prompt: "Leave your details", config: { fields: [{ id: "first_name", required: true }, { id: "email", required: true }] } },
1456
- { type: "end_screen", prompt: "Thanks for your response!" }
1457
- ]
1458
- }`,
1459
- inputSchema: {
1460
- title: z2.string().describe("Form title"),
1461
- questions: z2.array(NodeSchema).min(1).describe("Ordered list of questions/steps"),
1462
- show_step_counter: z2.boolean().optional().describe("Show step counter (e.g. '1/5'). Set true for quizzes."),
1463
- disable_back_navigation: z2.boolean().optional().describe("Prevent going back. Set true for quizzes."),
1464
- primary_color: z2.string().optional().describe("Primary/brand color (hex or CSS color). Used for buttons and accents."),
1465
- background_color: z2.string().optional().describe("Background color (hex, rgba, or CSS color)."),
1466
- font_family: z2.string().optional().describe("Font family name (e.g. 'Inter', 'Roboto', 'Playfair Display')."),
1467
- embed_autoplay: z2.boolean().optional().describe("Auto-play video when embedded (default: false). When off, embeds show a thumbnail + play button."),
1468
- tags: z2.array(z2.string()).optional().describe("Tags for indexing (e.g. ['quiz', 'trivia', 'arsenal']). Include format, genre, and topics.")
1469
- },
1470
- annotations: {
1471
- readOnlyHint: false,
1472
- destructiveHint: false,
1473
- idempotentHint: false,
1474
- openWorldHint: true
1475
- }
1476
- },
1477
- async ({ title, questions, show_step_counter, disable_back_navigation, primary_color, background_color, font_family, embed_autoplay, tags }) => {
1478
- let planContext = null;
1479
- const meResult = await callApi("/me", { method: "GET" });
1480
- if (!meResult.ok) {
1481
- return errorResult(`Unable to determine workspace: ${meResult.error}`);
1482
- }
1483
- const me = meResult.data;
1484
- const workspaceId = me.workspace?.id ?? null;
1485
- if (!workspaceId) {
1486
- return errorResult(
1487
- "No workspace available. Connect your Clipform account in claude.ai \u2192 Settings \u2192 Connectors, or ensure MCP_WORKSPACE_ID is configured for anonymous mode."
1488
- );
1489
- }
1490
- planContext = {
1491
- auth_mode: me.auth_mode,
1492
- workspace_id: workspaceId,
1493
- workspace_name: me.workspace?.name ?? null,
1494
- plan_name: me.plan?.name ?? "Free",
1495
- node_limit: me.plan?.node_limit ?? null
1496
- };
1497
- const contentCount = questions.filter((q) => q.type !== "end_screen").length;
1498
- if (planContext.node_limit !== null && contentCount > planContext.node_limit) {
1499
- const upgradeUrl = `${BUSINESS.urls.dashboard}/billing`;
1500
- const message = planContext.auth_mode === "oauth" ? `Your '${planContext.workspace_name}' workspace is on the ${planContext.plan_name} plan, capped at ${planContext.node_limit} questions per form. You asked for ${contentCount}. Either rerun with ${planContext.node_limit} questions or upgrade at ${upgradeUrl}.` : `Anonymous sessions are capped at ${planContext.node_limit} questions per form (${planContext.plan_name} plan). You asked for ${contentCount}. Either rerun with ${planContext.node_limit} questions, or connect your Clipform account in claude.ai \u2192 Settings \u2192 Connectors so forms land in your workspace with your real plan limits.`;
1501
- return errorResult(message);
1502
- }
1503
- const createResult = await callApi("/forms", {
1504
- method: "POST",
1505
- body: { title, workspace_id: workspaceId }
1506
- });
1507
- if (!createResult.ok) {
1508
- return errorResult(createResult.error);
1509
- }
1510
- const { data } = createResult;
1511
- const formId = data.form_id;
1512
- const claimUrl = data.claim_url ?? void 0;
1513
- const settingsBody = {};
1514
- if (show_step_counter !== void 0) settingsBody.show_step_counter = show_step_counter;
1515
- if (disable_back_navigation !== void 0) settingsBody.disable_back_navigation = disable_back_navigation;
1516
- if (primary_color !== void 0) settingsBody.primary_color = primary_color;
1517
- if (background_color !== void 0) settingsBody.background_color = background_color;
1518
- if (font_family !== void 0) settingsBody.font_family = font_family;
1519
- if (embed_autoplay !== void 0) settingsBody.embed_autoplay = embed_autoplay;
1520
- if (Object.keys(settingsBody).length > 0) {
1521
- await callApi(`/forms/${formId}`, {
1522
- method: "PATCH",
1523
- body: settingsBody
1524
- });
1525
- }
1526
- for (const q of questions) {
1527
- if (q.type === "end_screen") {
1528
- const getResult = await callApi(`/forms/${formId}`, {
1529
- method: "GET"
1530
- });
1531
- if (getResult.ok) {
1532
- const questions_list = getResult.data.questions;
1533
- const endScreen = questions_list?.find((qq) => qq.type === "end_screen");
1534
- if (endScreen) {
1535
- await callApi(`/forms/${formId}/nodes/${endScreen.id}`, {
1536
- method: "PATCH",
1537
- body: { prompt: q.prompt, ...q.config ? { config: q.config } : {} }
1538
- });
1539
- continue;
1540
- }
1541
- }
1542
- }
1543
- const addResult = await callApi(`/forms/${formId}/nodes`, {
1544
- method: "POST",
1545
- body: { question: q }
1546
- });
1547
- if (!addResult.ok) {
1548
- return errorResult(`Failed to add question "${q.prompt}": ${addResult.error}`);
1549
- }
1550
- }
1551
- await callApi(`/forms/${formId}`, {
1552
- method: "PATCH",
1553
- body: { is_published: true }
1554
- });
1555
- if (tags && tags.length > 0) {
1556
- await callApi(`/forms/${formId}/tags`, {
1557
- method: "PUT",
1558
- body: { tags }
1559
- });
1560
- }
1561
- const lines = [
1562
- `Form created successfully!`,
1563
- ``,
1564
- `Title: ${title}`,
1565
- `Questions: ${questions.length}`,
1566
- `Form ID: ${formId}`,
1567
- ``,
1568
- `FORM URL (share this with respondents): ${data.viewer_url}`
1569
- ];
1570
- if (claimUrl) {
1571
- lines.push(`CLAIM URL (transfer ownership to your account): ${claimUrl}`);
1572
- }
1573
- lines.push(
1574
- ``,
1575
- `IMPORTANT: Always show the user the exact URLs above \u2014 do not rewrite or modify them.`,
1576
- `Pass form_id on follow-up tools (get_form, add_node, upload_node_media, etc.).`
1577
- );
1578
- if (planContext) {
1579
- const limitNote = planContext.node_limit === null ? "unlimited questions" : `up to ${planContext.node_limit} questions per form`;
1580
- if (planContext.auth_mode === "oauth") {
1581
- lines.push(
1582
- ``,
1583
- `Plan: ${planContext.plan_name} (${limitNote}) in workspace '${planContext.workspace_name}'.`
1584
- );
1585
- } else {
1586
- lines.push(
1587
- ``,
1588
- `Plan: anonymous ${planContext.plan_name} (${limitNote}). Mention this once: the user can connect their Clipform account in claude.ai \u2192 Settings \u2192 Connectors \u2192 ${BUSINESS.urls.mcp} so future forms land directly in their workspace - do not repeat on follow-up calls.`
1589
- );
1590
- }
1591
- }
1592
- return textResult(lines.join("\n"));
1593
- }
1594
- );
1595
- }
1596
-
1597
- // src/tools/list-forms.ts
1598
- import { z as z3 } from "zod";
1599
- function registerListFormsTool(server) {
1600
- server.registerTool(
1601
- "clipform_list_forms",
1602
- {
1603
- title: "List Clipforms",
1604
- description: `List forms in your workspace with optional filtering. Returns paginated results (cursor-based). Use the next_cursor value to fetch the next page.`,
1605
- inputSchema: {
1606
- limit: z3.number().int().min(1).max(100).optional().describe("Number of forms to return (default 25, max 100)"),
1607
- cursor: z3.string().optional().describe(
1608
- "Pagination cursor from previous response's next_cursor"
1609
- ),
1610
- tag: z3.string().optional().describe(
1611
- "Filter by tag name(s), comma-separated. AND logic: only forms with ALL tags are returned."
1612
- ),
1613
- published: z3.enum(["true", "false"]).optional().describe("Filter by publish status"),
1614
- search: z3.string().optional().describe("Search forms by title (case-insensitive substring match)"),
1615
- sort: z3.enum(["created_at", "updated_at"]).optional().describe("Sort field (default: created_at)"),
1616
- order: z3.enum(["asc", "desc"]).optional().describe("Sort order (default: desc, newest first)")
1617
- },
1618
- annotations: {
1619
- readOnlyHint: true,
1620
- destructiveHint: false,
1621
- idempotentHint: true,
1622
- openWorldHint: true
1623
- }
1624
- },
1625
- async ({ limit, cursor, tag, published, search, sort, order }) => {
1626
- const params = { include: "tags" };
1627
- if (limit !== void 0) params.limit = String(limit);
1628
- if (cursor) params.cursor = cursor;
1629
- if (tag) params.tag = tag;
1630
- if (published) params.published = published;
1631
- if (search) params.search = search;
1632
- if (sort) params.sort = sort;
1633
- if (order) params.order = order;
1634
- const result = await callApi("/forms", { params });
1635
- if (!result.ok) {
1636
- return errorResult(result.error);
1637
- }
1638
- const data = result.data;
1639
- if (data.forms.length === 0) {
1640
- return textResult("No forms found matching the criteria.");
1641
- }
1642
- const lines = [`Found ${data.forms.length} form(s):
1643
- `];
1644
- for (const f of data.forms) {
1645
- const status = f.is_published ? "published" : "draft";
1646
- const tagStr = f.tags.length > 0 ? ` [${f.tags.map((t) => t.name).join(", ")}]` : "";
1647
- lines.push(`- **${f.title || "(untitled)"}** [${status}]${tagStr}`);
1648
- lines.push(` ID: ${f.id}`);
1649
- lines.push(` Share ID: ${f.share_id}`);
1650
- lines.push(` Created: ${f.created_at}`);
1651
- lines.push("");
1652
- }
1653
- if (data.next_cursor) {
1654
- lines.push(
1655
- `More results available. Pass cursor: "${data.next_cursor}" to get the next page.`
1656
- );
1657
- }
1658
- return textResult(lines.join("\n"));
1659
- }
1660
- );
1661
- }
1662
-
1663
- // src/tools/get-form.ts
1664
- import { z as z4 } from "zod";
1665
-
1666
- // src/lib/format-form.ts
1667
- function formatFormState(data) {
1668
- const questions = data.questions;
1669
- const lines = [
1670
- `Form: ${data.title}`,
1671
- `Form ID: ${data.form_id}`,
1672
- `Published: ${data.is_published}`,
1673
- ``,
1674
- `Nodes (in order):`
1675
- ];
1676
- for (let i = 0; i < questions.length; i++) {
1677
- const q = questions[i];
1678
- lines.push(` ${i + 1}. [${q.type}] ${q.prompt || "(no prompt)"}`);
1679
- lines.push(` Node ID: ${q.id}`);
1680
- if (q.required) lines.push(` Required: yes`);
1681
- if (q.config && Object.keys(q.config).length > 0) {
1682
- lines.push(` Config: ${JSON.stringify(q.config)}`);
1683
- }
1684
- if (q.options && q.options.length > 0) {
1685
- lines.push(
1686
- ` Options: ${q.options.map((o) => o.content).join(", ")}`
1687
- );
1688
- }
1689
- }
1690
- return lines.join("\n");
1691
- }
1692
- async function fetchAndFormatFormState(formId) {
1693
- const result = await callApi(`/forms/${formId}`);
1694
- if (!result.ok) return null;
1695
- return formatFormState(result.data);
1696
- }
1697
-
1698
- // src/tools/get-form.ts
1699
- function registerGetFormTool(server) {
1700
- server.registerTool(
1701
- "clipform_get_form",
1702
- {
1703
- title: "Get Clipform",
1704
- description: `Retrieve a form's details including all questions in sequential order. Use this to see the current state of a form before making changes.`,
1705
- inputSchema: {
1706
- form_id: z4.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)")
1707
- },
1708
- annotations: {
1709
- readOnlyHint: true,
1710
- destructiveHint: false,
1711
- idempotentHint: true,
1712
- openWorldHint: true
1713
- }
1714
- },
1715
- async ({ form_id }) => {
1716
- const result = await callApi(`/forms/${form_id}`);
1717
- if (!result.ok) {
1718
- return errorResult(result.error);
1719
- }
1720
- return textResult(formatFormState(result.data));
1721
- }
1722
- );
1723
- }
1724
-
1725
- // src/tools/update-form.ts
1726
- import { z as z5 } from "zod";
1727
- function registerUpdateFormTool(server) {
1728
- server.registerTool(
1729
- "clipform_update_form",
1730
- {
1731
- title: "Update Clipform",
1732
- description: `Update a form's title, publish status, settings, or tags. Use clipform_get_form first to see current values.`,
1733
- inputSchema: {
1734
- form_id: z5.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
1735
- title: z5.string().optional().describe("New form title"),
1736
- is_published: z5.boolean().optional().describe("Set to true to publish, false to unpublish"),
1737
- show_step_counter: z5.boolean().optional().describe("Show step counter (e.g. '1/5'). Recommended for quizzes."),
1738
- disable_back_navigation: z5.boolean().optional().describe("Prevent respondents from going back. Recommended for quizzes."),
1739
- total_steps: z5.number().nullable().optional().describe("Override the total step count shown in the step counter. Set null to auto-calculate."),
1740
- primary_color: z5.string().optional().describe("Primary/brand color (hex or CSS color). Used for buttons and accents."),
1741
- background_color: z5.string().optional().describe("Background color (hex, rgba, or CSS color)."),
1742
- font_family: z5.string().optional().describe("Font family name (e.g. 'Inter', 'Roboto', 'Playfair Display')."),
1743
- embed_autoplay: z5.boolean().optional().describe("Auto-play video when embedded (default: false). When off, embeds show a thumbnail + play button."),
1744
- description: z5.string().nullable().optional().describe("SEO description (meta description, og:description). Set null to clear."),
1745
- author: z5.string().nullable().optional().describe("Author/brand name shown to respondents. Set null to clear."),
1746
- brand_name: z5.string().nullable().optional().describe("Brand name shown alongside the logo in the viewer. Set null to clear."),
1747
- logo_url: z5.string().nullable().optional().describe("URL to a logo image shown in the viewer header. Set null to clear."),
1748
- tags: z5.array(z5.string()).optional().describe("Replace all tags on this form. Pass the full desired set (e.g. ['quiz', 'trivia', 'slug:elephants']). Omit to leave tags unchanged.")
1749
- },
1750
- annotations: {
1751
- readOnlyHint: false,
1752
- destructiveHint: false,
1753
- idempotentHint: true,
1754
- openWorldHint: true
1755
- }
1756
- },
1757
- async ({ form_id, title, is_published, show_step_counter, disable_back_navigation, total_steps, primary_color, background_color, font_family, embed_autoplay, description, author, brand_name, logo_url, tags }) => {
1758
- const body = {};
1759
- if (title !== void 0) body.title = title;
1760
- if (is_published !== void 0) body.is_published = is_published;
1761
- if (show_step_counter !== void 0) body.show_step_counter = show_step_counter;
1762
- if (disable_back_navigation !== void 0) body.disable_back_navigation = disable_back_navigation;
1763
- if (total_steps !== void 0) body.total_steps = total_steps;
1764
- if (primary_color !== void 0) body.primary_color = primary_color;
1765
- if (background_color !== void 0) body.background_color = background_color;
1766
- if (font_family !== void 0) body.font_family = font_family;
1767
- if (embed_autoplay !== void 0) body.embed_autoplay = embed_autoplay;
1768
- if (description !== void 0) body.description = description;
1769
- if (author !== void 0) body.author = author;
1770
- if (brand_name !== void 0) body.brand_name = brand_name;
1771
- if (logo_url !== void 0) body.logo_url = logo_url;
1772
- if (Object.keys(body).length > 0) {
1773
- const result = await callApi(`/forms/${form_id}`, {
1774
- method: "PATCH",
1775
- body
1776
- });
1777
- if (!result.ok) {
1778
- return errorResult(result.error);
1779
- }
1780
- }
1781
- if (tags) {
1782
- const tagResult = await callApi(`/forms/${form_id}/tags`, {
1783
- method: "PUT",
1784
- body: { tags }
1785
- });
1786
- if (!tagResult.ok) {
1787
- return errorResult(tagResult.error);
1788
- }
1789
- }
1790
- const updates = [];
1791
- if (title !== void 0) updates.push(`Title \u2192 "${title}"`);
1792
- if (is_published !== void 0)
1793
- updates.push(`Published \u2192 ${is_published}`);
1794
- if (show_step_counter !== void 0)
1795
- updates.push(`Step counter \u2192 ${show_step_counter}`);
1796
- if (disable_back_navigation !== void 0)
1797
- updates.push(`Back navigation \u2192 ${disable_back_navigation ? "disabled" : "enabled"}`);
1798
- if (total_steps !== void 0)
1799
- updates.push(`Total steps \u2192 ${total_steps === null ? "auto" : total_steps}`);
1800
- if (primary_color !== void 0)
1801
- updates.push(`Primary color \u2192 ${primary_color}`);
1802
- if (background_color !== void 0)
1803
- updates.push(`Background color \u2192 ${background_color}`);
1804
- if (font_family !== void 0)
1805
- updates.push(`Font \u2192 ${font_family}`);
1806
- if (embed_autoplay !== void 0)
1807
- updates.push(`Embed autoplay \u2192 ${embed_autoplay}`);
1808
- if (description !== void 0)
1809
- updates.push(`Description \u2192 ${description === null ? "cleared" : `"${description}"`}`);
1810
- if (author !== void 0)
1811
- updates.push(`Author \u2192 ${author === null ? "cleared" : `"${author}"`}`);
1812
- if (brand_name !== void 0)
1813
- updates.push(`Brand name \u2192 ${brand_name === null ? "cleared" : `"${brand_name}"`}`);
1814
- if (logo_url !== void 0)
1815
- updates.push(`Logo URL \u2192 ${logo_url === null ? "cleared" : logo_url}`);
1816
- if (tags)
1817
- updates.push(`Tags \u2192 [${tags.join(", ")}]`);
1818
- const confirmMsg = `Form updated:
1819
- ${updates.join("\n")}`;
1820
- const formState = await fetchAndFormatFormState(form_id);
1821
- return textResult(formState ? `${confirmMsg}
1822
-
1823
- ---
1824
-
1825
- ${formState}` : confirmMsg);
1826
- }
1827
- );
1828
- }
1829
-
1830
- // src/tools/delete-form.ts
1831
- import { z as z6 } from "zod";
1832
- function registerDeleteFormTool(server) {
1833
- server.registerTool(
1834
- "clipform_delete_form",
1835
- {
1836
- title: "Delete Clipform",
1837
- description: `Permanently delete an unclaimed form and all its questions. This cannot be undone. Only works on forms that haven't been claimed yet.`,
1838
- inputSchema: {
1839
- form_id: z6.string().uuid().describe("The form UUID to delete (returned by clipform_create_form, not the short share_id from the URL)")
1840
- },
1841
- annotations: {
1842
- readOnlyHint: false,
1843
- destructiveHint: true,
1844
- idempotentHint: false,
1845
- openWorldHint: true
1846
- }
1847
- },
1848
- async ({ form_id }) => {
1849
- const result = await callApi(`/forms/${form_id}`, {
1850
- method: "DELETE"
1851
- });
1852
- if (!result.ok) {
1853
- return errorResult(result.error);
1854
- }
1855
- return textResult(`Form ${form_id} has been permanently deleted.`);
1856
- }
1857
- );
1858
- }
1859
-
1860
- // src/tools/add-node.ts
1861
- import { z as z7 } from "zod";
1862
- function registerAddNodeTool(server) {
1863
- server.registerTool(
1864
- "clipform_add_node",
1865
- {
1866
- title: "Add Node",
1867
- description: `Add a new node to an existing form. By default, the node is inserted before the end screen (appended to the end of the flow). Use after_node_id to insert at a specific position.
1868
-
1869
- ${NODE_TYPES_DESCRIPTION}
1870
-
1871
- All type definitions and config schemas are derived from @vid-master/config (answer-types).`,
1872
- inputSchema: {
1873
- form_id: z7.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
1874
- question: NodeSchema.describe("The node to add"),
1875
- after_node_id: z7.string().optional().describe(
1876
- "Insert after this node ID. Omit to append before the end screen."
1877
- )
1878
- },
1879
- annotations: {
1880
- readOnlyHint: false,
1881
- destructiveHint: false,
1882
- idempotentHint: false,
1883
- openWorldHint: true
1884
- }
1885
- },
1886
- async ({ form_id, question, after_node_id }) => {
1887
- const body = { question };
1888
- if (after_node_id) body.after_node_id = after_node_id;
1889
- const result = await callApi(`/forms/${form_id}/nodes`, {
1890
- method: "POST",
1891
- body
1892
- });
1893
- if (!result.ok) {
1894
- return errorResult(result.error);
1895
- }
1896
- const confirmMsg = [
1897
- `Node added successfully!`,
1898
- `Node ID: ${result.data.node_id}`,
1899
- `Type: ${question.type}`,
1900
- `Prompt: ${question.prompt}`
1901
- ].join("\n");
1902
- const formState = await fetchAndFormatFormState(form_id);
1903
- return textResult(formState ? `${confirmMsg}
1904
-
1905
- ---
1906
-
1907
- ${formState}` : confirmMsg);
1908
- }
1909
- );
1910
- }
1911
-
1912
- // src/tools/update-node.ts
1913
- import { z as z8 } from "zod";
1914
- function registerUpdateNodeTool(server) {
1915
- server.registerTool(
1916
- "clipform_update_node",
1917
- {
1918
- title: "Update Node",
1919
- description: `Update an existing node's text, type, config, or options. Use clipform_get_form first to find the node ID. Does not change the node's position in the flow. All type definitions and config schemas are derived from @vid-master/config (answer-types).`,
1920
- inputSchema: {
1921
- form_id: z8.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
1922
- node_id: z8.string().describe("The node ID to update"),
1923
- prompt: z8.string().optional().describe("New question text"),
1924
- label: z8.string().optional().describe("Short label for the question (used in logic builder)"),
1925
- type: z8.enum(ACTIVE_NODE_TYPES).optional().describe("Change the question type"),
1926
- required: z8.boolean().optional().describe("Whether an answer is required"),
1927
- config: z8.record(z8.unknown()).optional().describe(CONFIG_DESCRIPTION),
1928
- options: z8.array(OptionSchema).optional().describe(
1929
- "Replace all options (for choice questions). Omit to keep existing options."
1930
- )
1931
- },
1932
- annotations: {
1933
- readOnlyHint: false,
1934
- destructiveHint: false,
1935
- idempotentHint: true,
1936
- openWorldHint: true
1937
- }
1938
- },
1939
- async ({
1940
- form_id,
1941
- node_id,
1942
- prompt,
1943
- label,
1944
- type,
1945
- required,
1946
- config,
1947
- options
1948
- }) => {
1949
- const body = {};
1950
- if (prompt !== void 0) body.prompt = prompt;
1951
- if (label !== void 0) body.label = label;
1952
- if (type !== void 0) body.type = type;
1953
- if (required !== void 0) body.required = required;
1954
- if (config !== void 0) body.config = config;
1955
- if (options !== void 0) body.options = options;
1956
- const result = await callApi(
1957
- `/forms/${form_id}/nodes/${node_id}`,
1958
- { method: "PATCH", body }
1959
- );
1960
- if (!result.ok) {
1961
- return errorResult(result.error);
1962
- }
1963
- const updates = [];
1964
- if (prompt !== void 0) updates.push(`Prompt \u2192 "${prompt}"`);
1965
- if (label !== void 0) updates.push(`Label \u2192 "${label}"`);
1966
- if (type !== void 0) updates.push(`Type \u2192 ${type}`);
1967
- if (required !== void 0) updates.push(`Required \u2192 ${required}`);
1968
- if (config !== void 0) updates.push(`Config updated`);
1969
- if (options !== void 0)
1970
- updates.push(`Options \u2192 ${options.map((o) => o.content).join(", ")}`);
1971
- const confirmMsg = `Node ${node_id} updated:
1972
- ${updates.join("\n")}`;
1973
- const formState = await fetchAndFormatFormState(form_id);
1974
- return textResult(formState ? `${confirmMsg}
1975
-
1976
- ---
1977
-
1978
- ${formState}` : confirmMsg);
1979
- }
1980
- );
1981
- }
1982
-
1983
- // src/tools/delete-node.ts
1984
- import { z as z9 } from "zod";
1985
- function registerDeleteNodeTool(server) {
1986
- server.registerTool(
1987
- "clipform_delete_node",
1988
- {
1989
- title: "Delete Node",
1990
- description: `Delete a node from a form. The logic chain is automatically re-linked (the previous node will point to the next one). Cannot delete the start node or the last end screen.`,
1991
- inputSchema: {
1992
- form_id: z9.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
1993
- node_id: z9.string().describe("The node ID to delete")
1994
- },
1995
- annotations: {
1996
- readOnlyHint: false,
1997
- destructiveHint: true,
1998
- idempotentHint: false,
1999
- openWorldHint: true
2000
- }
2001
- },
2002
- async ({ form_id, node_id }) => {
2003
- const result = await callApi(
2004
- `/forms/${form_id}/nodes/${node_id}`,
2005
- { method: "DELETE" }
2006
- );
2007
- if (!result.ok) {
2008
- return errorResult(result.error);
2009
- }
2010
- const confirmMsg = `Node ${node_id} deleted. The logic chain has been re-linked.`;
2011
- const formState = await fetchAndFormatFormState(form_id);
2012
- return textResult(formState ? `${confirmMsg}
2013
-
2014
- ---
2015
-
2016
- ${formState}` : confirmMsg);
2017
- }
2018
- );
2019
- }
2020
-
2021
- // src/tools/upload-node-media.ts
2022
- import { z as z10 } from "zod";
2023
- var MediaItemSchema = z10.object({
2024
- node_id: z10.string().describe("The node ID"),
2025
- media_type: z10.enum(["video", "still"]).describe("Type of media"),
2026
- media_source: z10.enum(["uploaded", "recorded"]).default("uploaded").describe("How the media was created"),
2027
- url: z10.string().url().optional().describe("Public URL of the media file. When provided, the server fetches and stores it directly."),
2028
- captions: z10.array(
2029
- z10.object({
2030
- start: z10.number().describe("Segment start time in seconds"),
2031
- end: z10.number().describe("Segment end time in seconds"),
2032
- text: z10.string().describe("Full segment text"),
2033
- words: z10.array(
2034
- z10.object({
2035
- word: z10.string(),
2036
- start: z10.number(),
2037
- end: z10.number()
2038
- })
2039
- ).optional().describe("Per-word timestamps within the segment")
2040
- })
2041
- ).optional().describe("Word-level captions from clipform_generate_tts. Always include when uploading narrated video - pass the full objects including 'words' so per-word highlighting works."),
2042
- show_captions: z10.boolean().optional().default(true).describe("Display captions/subtitles on the question")
2043
- });
2044
- function registerUploadNodeMediaTool(server) {
2045
- server.registerTool(
2046
- "clipform_upload_node_media",
2047
- {
2048
- title: "Upload Node Media",
2049
- description: `Upload media for one or more nodes. Pass one item or many (max 10). Multiple items upload sequentially.
2050
-
2051
- When a public URL is provided, the media is fetched and stored automatically. Only works on node types that support media (choice, open, scale, button). For video: ingested via Mux. For image: stored in Supabase. When attaching TTS narration video, always include captions from clipform_generate_tts.`,
2052
- inputSchema: {
2053
- form_id: z10.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
2054
- items: z10.array(MediaItemSchema).min(1).max(10).describe("One or more media items to upload")
2055
- },
2056
- annotations: {
2057
- readOnlyHint: false,
2058
- destructiveHint: false,
2059
- idempotentHint: false,
2060
- openWorldHint: true
2061
- }
2062
- },
2063
- async ({ form_id, items }) => {
2064
- const lines = [];
2065
- let successCount = 0;
2066
- for (let i = 0; i < items.length; i++) {
2067
- const item = items[i];
2068
- if (items.length > 1) lines.push(`--- Item ${i + 1} (node ${item.node_id}) ---`);
2069
- const body = {
2070
- media_type: item.media_type,
2071
- media_source: item.media_source
2072
- };
2073
- if (item.url) body.url = item.url;
2074
- if (item.captions) body.captions = item.captions;
2075
- if (item.show_captions !== void 0) body.show_captions = item.show_captions;
2076
- const result = await callApi(
2077
- `/forms/${form_id}/nodes/${item.node_id}/media`,
2078
- { method: "POST", body }
2079
- );
2080
- if (result.ok) {
2081
- successCount++;
2082
- const resultLines = [`Media ID: ${result.data.media_id}`];
2083
- if (result.data.upload_url) {
2084
- resultLines.push(
2085
- `Upload URL: ${result.data.upload_url}`,
2086
- `Upload method: ${result.data.upload_method}`,
2087
- `Upload the file directly to the upload URL using ${result.data.upload_method === "tus" ? "TUS resumable upload" : "HTTP PUT"}.`
2088
- );
2089
- }
2090
- if (item.captions) {
2091
- resultLines.push(`Captions: ${item.captions.length} segments saved`);
2092
- }
2093
- lines.push(resultLines.join("\n"));
2094
- } else {
2095
- lines.push(`FAILED: ${result.error}`);
2096
- }
2097
- lines.push("");
2098
- }
2099
- if (items.length > 1) {
2100
- lines.unshift(`Media upload: ${successCount}/${items.length} succeeded
2101
- `);
2102
- } else if (successCount > 0) {
2103
- lines.unshift(`Media uploaded successfully.`);
2104
- }
2105
- if (successCount === 0) return errorResult(lines.join("\n"));
2106
- return textResult(lines.join("\n"));
2107
- }
2108
- );
2109
- }
2110
-
2111
- // src/tools/get-node-media.ts
2112
- import { z as z11 } from "zod";
2113
- function registerGetNodeMediaTool(server) {
2114
- server.registerTool(
2115
- "clipform_get_node_media",
2116
- {
2117
- title: "Get Node Media",
2118
- description: `Get the media attached to a node, including processing status. Useful for checking if a video upload has finished processing.`,
2119
- inputSchema: {
2120
- form_id: z11.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
2121
- node_id: z11.string().describe("The node ID")
2122
- },
2123
- annotations: {
2124
- readOnlyHint: true,
2125
- destructiveHint: false,
2126
- idempotentHint: true,
2127
- openWorldHint: true
2128
- }
2129
- },
2130
- async ({ form_id, node_id }) => {
2131
- const result = await callApi(
2132
- `/forms/${form_id}/nodes/${node_id}/media`
2133
- );
2134
- if (!result.ok) {
2135
- return errorResult(result.error);
2136
- }
2137
- const media = result.data.media;
2138
- if (!media) {
2139
- return textResult("No media attached to this node.");
2140
- }
2141
- return textResult(
2142
- `Media ID: ${media.id}
2143
- Type: ${media.media_type}
2144
- Status: ${media.status}
2145
- ` + (media.playback_id ? `Playback ID: ${media.playback_id}
2146
- ` : "") + (media.storage_path ? `Storage path: ${media.storage_path}
2147
- ` : "") + (media.duration ? `Duration: ${media.duration}s
2148
- ` : "") + `Transcription: ${media.transcription_status}`
2149
- );
2150
- }
2151
- );
2152
- }
2153
-
2154
- // src/tools/delete-node-media.ts
2155
- import { z as z12 } from "zod";
2156
- function registerDeleteNodeMediaTool(server) {
2157
- server.registerTool(
2158
- "clipform_delete_node_media",
2159
- {
2160
- title: "Delete Node Media",
2161
- description: `Remove media from a node. Deletes the media record and cleans up external resources (Mux video asset, storage file).`,
2162
- inputSchema: {
2163
- form_id: z12.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
2164
- node_id: z12.string().describe("The node ID")
2165
- },
2166
- annotations: {
2167
- readOnlyHint: false,
2168
- destructiveHint: true,
2169
- idempotentHint: false,
2170
- openWorldHint: true
2171
- }
2172
- },
2173
- async ({ form_id, node_id }) => {
2174
- const result = await callApi(
2175
- `/forms/${form_id}/nodes/${node_id}/media`,
2176
- { method: "DELETE" }
2177
- );
2178
- if (!result.ok) {
2179
- return errorResult(result.error);
2180
- }
2181
- return textResult("Media removed from node.");
2182
- }
2183
- );
2184
- }
2185
-
2186
- // src/tools/set-node-logic.ts
2187
- import { z as z13 } from "zod";
2188
- var LogicRuleSchema = z13.object({
2189
- option_content: z13.string().optional().describe(
2190
- "The option text to match. Omit for a default rule (applies to all unmatched options)."
2191
- ),
2192
- target_node_id: z13.string().describe("The node ID to jump to when this rule matches")
2193
- });
2194
- function registerSetNodeLogicTool(server) {
2195
- server.registerTool(
2196
- "clipform_set_logic",
2197
- {
2198
- title: "Set Node Logic",
2199
- description: `Set branching logic on a node. Routes respondents to different nodes based on their answer.
2200
-
2201
- Each rule maps an option (by its text content) to a target node. Rules without option_content are "default" rules applied to all unmatched options.
2202
-
2203
- Example: Route correct answer to a "Well done" node, everything else to "Wrong":
2204
- rules: [
2205
- { option_content: "42", target_node_id: "well-done-id" },
2206
- { target_node_id: "wrong-id" }
2207
- ]`,
2208
- inputSchema: {
2209
- form_id: z13.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
2210
- node_id: z13.string().describe("The node ID to set logic on"),
2211
- rules: z13.array(LogicRuleSchema).min(1).describe("Branching rules. Each maps an option or default to a target node.")
2212
- },
2213
- annotations: {
2214
- readOnlyHint: false,
2215
- destructiveHint: false,
2216
- idempotentHint: true,
2217
- openWorldHint: true
2218
- }
2219
- },
2220
- async ({ form_id, node_id, rules }) => {
2221
- const result = await callApi(
2222
- `/forms/${form_id}/nodes/${node_id}/logic`,
2223
- {
2224
- method: "PUT",
2225
- body: { rules }
2226
- }
2227
- );
2228
- if (!result.ok) {
2229
- return errorResult(result.error);
2230
- }
2231
- const confirmMsg = `Logic set on node ${node_id}.
2232
- Rules created: ${result.data.rules_count}`;
2233
- const formState = await fetchAndFormatFormState(form_id);
2234
- return textResult(formState ? `${confirmMsg}
2235
-
2236
- ---
2237
-
2238
- ${formState}` : confirmMsg);
2239
- }
2240
- );
2241
- }
2242
-
2243
- // src/tools/attach-node-audio.ts
2244
- import { z as z14 } from "zod";
2245
- function registerAttachNodeAudioTool(server) {
2246
- server.registerTool(
2247
- "clipform_attach_audio",
2248
- {
2249
- title: "Attach Audio to Node",
2250
- description: `Attach a sound effect or audio file to a node with existing media (stills only). The audio auto-plays once when a respondent reaches this node. Provide a public URL to the audio file (WAV, MP3, or OGG). The node must already have media uploaded (use clipform_upload_node_media first).`,
2251
- inputSchema: {
2252
- form_id: z14.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
2253
- node_id: z14.string().describe("The node ID (must already have media attached)"),
2254
- url: z14.string().url().describe("Public URL to the audio file (WAV, MP3, or OGG)")
2255
- },
2256
- annotations: {
2257
- readOnlyHint: false,
2258
- destructiveHint: false,
2259
- idempotentHint: true,
2260
- openWorldHint: true
2261
- }
2262
- },
2263
- async ({ form_id, node_id, url }) => {
2264
- const result = await callApi(
2265
- `/forms/${form_id}/nodes/${node_id}/audio`,
2266
- {
2267
- method: "PUT",
2268
- body: { url }
2269
- }
2270
- );
2271
- if (!result.ok) {
2272
- return errorResult(result.error);
2273
- }
2274
- return textResult(
2275
- `Audio attached to node ${node_id}.
2276
- Audio path: ${result.data.audio_storage_path}`
2277
- );
2278
- }
2279
- );
2280
- }
2281
-
2282
- // src/tools/log-generation.ts
2283
- import { z as z15 } from "zod";
2284
- function registerLogGenerationTool(server) {
2285
- server.registerTool(
2286
- "clipform_log_generation",
2287
- {
2288
- title: "Log Form Generation Audit",
2289
- description: `Save an audit trail for a generated form. Records where content came from - the sources and image attributions. Call this as the final step after building a form or quiz.`,
2290
- inputSchema: {
2291
- form_id: z15.string().uuid().describe("The form ID (UUID format, not the share ID)"),
2292
- summary: z15.string().describe("Short description of what was generated"),
2293
- details: z15.object({
2294
- sources: z15.array(z15.object({
2295
- title: z15.string(),
2296
- url: z15.string().optional(),
2297
- type: z15.string().optional()
2298
- })).optional().describe("Knowledge sources used"),
2299
- images: z15.array(z15.object({
2300
- url: z15.string(),
2301
- attribution: z15.string().optional(),
2302
- license: z15.string().optional()
2303
- })).optional().describe("Images used with attribution")
2304
- }).describe("Content sources and attributions")
2305
- },
2306
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
2307
- },
2308
- async ({ form_id, summary, details }) => {
2309
- const result = await callInternalApi("/internal/log-generation", {
2310
- body: { form_id, summary, details }
2311
- });
2312
- if (!result.ok) return errorResult(result.error);
2313
- return textResult(`Audit log saved for form ${form_id}
2314
- Summary: ${summary}`);
2315
- }
2316
- );
2317
- }
2318
-
2319
- // src/tools/search-news.ts
2320
- import { z as z16 } from "zod";
2321
- function registerSearchNewsTool(server) {
2322
- server.registerTool(
2323
- "clipform_search_news",
2324
- {
2325
- title: "Search News (fallback)",
2326
- description: `FALLBACK news lookup for clients without native web search. Returns structured current-news articles from NewsAPI and The Guardian.
2327
-
2328
- WHEN TO USE:
2329
- - The user asks for a quiz about an event, person, or topic that is recent (post-May-2025) or that you are not confident you know accurately.
2330
- - Your client does NOT already expose a native web search / web fetch tool. If it does (e.g. WebSearch in Claude Code, web search in Claude Desktop), prefer that - it is broader and more current than this tool.
2331
- - You would otherwise be at risk of hallucinating facts.
2332
-
2333
- DO NOT USE for timeless topics you already know well (history, geography, science, general knowledge) - write those from your own knowledge, it is faster and the result is better.
2334
-
2335
- If neither native web search nor this tool is available and the topic is post-cutoff or uncertain, REFUSE rather than fabricate.`,
2336
- inputSchema: {
2337
- query: z16.string().describe("News search query (e.g. 'Iran war 2026', 'Australian Open final', 'UK election')"),
2338
- count: z16.number().min(1).max(15).default(5).optional().describe("Max results per provider (default 5)")
2339
- },
2340
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
2341
- },
2342
- async ({ query, count }) => {
2343
- const result = await callInternalApi("/internal/search-news", {
2344
- body: { query, count }
2345
- });
2346
- if (!result.ok) return errorResult(result.error);
2347
- const results = result.data.results;
2348
- if (!results.length) return textResult(`No news articles found for "${query}".`);
2349
- const lines = [`Found ${results.length} articles for "${query}":
2350
- `];
2351
- for (const article of results) {
2352
- lines.push(`- [${article.source}] ${article.title}`);
2353
- if (article.description) lines.push(` ${article.description}`);
2354
- if (article.author) lines.push(` Author: ${article.author}`);
2355
- lines.push(` Date: ${article.publishedAt}`);
2356
- lines.push(` URL: ${article.url}`);
2357
- if (article.imageUrl) lines.push(` Image: ${article.imageUrl}`);
2358
- lines.push("");
2359
- }
2360
- return textResult(lines.join("\n"));
2361
- }
2362
- );
2363
- }
2364
-
2365
- // src/tools/generate-tts.ts
2366
- import { z as z17 } from "zod";
2367
- var TtsItemSchema = z17.object({
2368
- text: z17.string().min(1).max(5e3).describe("Narration text"),
2369
- voice: z17.enum(["ryan", "sonia", "andrew", "ava", "guy"]).optional().default("ryan").describe("TTS voice (default: ryan)")
2370
- });
2371
- function registerGenerateTtsTool(server) {
2372
- server.registerTool(
2373
- "clipform_generate_tts",
2374
- {
2375
- title: "Generate Text-to-Speech",
2376
- description: `Generate narration audio from text using Edge TTS (free) with ElevenLabs fallback. Pass one item or many (max 10) - multiple items run in parallel.
2377
-
2378
- Workflow:
2379
- 1. Generate TTS audio with this tool
2380
- 2. Search for images with clipform_search_media (kind: "image")
2381
- 3. Create a slideshow with clipform_generate_slideshow (pass the audio URL + image URLs)
2382
- 4. Attach the video to a node with clipform_upload_node_media
2383
-
2384
- Available voices: ryan (British male, default), sonia (British female), andrew (American male), ava (American female), guy (American male casual).
2385
-
2386
- Returns audio URL and word-level captions per item.`,
2387
- inputSchema: {
2388
- items: z17.array(TtsItemSchema).min(1).max(10).describe("One or more TTS items to generate")
2389
- },
2390
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
2391
- },
2392
- async ({ items }) => {
2393
- const workspace_id = process.env.MCP_WORKSPACE_ID;
2394
- const results = await Promise.allSettled(
2395
- items.map(
2396
- (item) => callInternalApi("/internal/tts", {
2397
- body: { text: item.text, voice: item.voice, workspace_id }
2398
- })
2399
- )
2400
- );
2401
- const lines = [];
2402
- let successCount = 0;
2403
- for (let i = 0; i < results.length; i++) {
2404
- const r = results[i];
2405
- if (items.length > 1) lines.push(`--- Item ${i + 1} ---`);
2406
- if (r.status === "fulfilled" && r.value.ok) {
2407
- successCount++;
2408
- const data = r.value.data;
2409
- lines.push(`Voice: ${data.voice}`);
2410
- lines.push(`Audio URL: ${data.audioUrl}`);
2411
- lines.push(`Storage path: ${data.storagePath}`);
2412
- lines.push(`Captions: ${JSON.stringify(data.captions)}`);
2413
- } else {
2414
- const error = r.status === "rejected" ? r.reason?.message || String(r.reason) : r.value.error;
2415
- const status = r.status === "fulfilled" ? r.value.status : 0;
2416
- const hint = status === 500 || status === 0 ? " (transient - retrying may help)" : "";
2417
- lines.push(`FAILED: ${error}${hint}`);
2418
- }
2419
- lines.push("");
2420
- }
2421
- if (items.length > 1) {
2422
- lines.unshift(`TTS: ${successCount}/${items.length} succeeded
2423
- `);
2424
- }
2425
- lines.push(
2426
- `IMPORTANT: When uploading the final video with clipform_upload_node_media, pass the COMPLETE Captions JSON above as the "captions" parameter \u2014 including the "words" arrays inside each segment. Do NOT strip or simplify the JSON. The viewer needs word-level timing data to display captions.`
2427
- );
2428
- if (successCount === 0) return errorResult(lines.join("\n"));
2429
- return textResult(lines.join("\n"));
2430
- }
2431
- );
2432
- }
2433
-
2434
- // src/tools/generate-slideshow.ts
2435
- import { z as z18 } from "zod";
2436
-
2437
- // ../../node_modules/uuid/dist/esm/stringify.js
2438
- var byteToHex = [];
2439
- for (let i = 0; i < 256; ++i) {
2440
- byteToHex.push((i + 256).toString(16).slice(1));
2441
- }
2442
- function unsafeStringify(arr, offset = 0) {
2443
- return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
2444
- }
2445
-
2446
- // ../../node_modules/uuid/dist/esm/rng.js
2447
- import { randomFillSync } from "crypto";
2448
- var rnds8Pool = new Uint8Array(256);
2449
- var poolPtr = rnds8Pool.length;
2450
- function rng() {
2451
- if (poolPtr > rnds8Pool.length - 16) {
2452
- randomFillSync(rnds8Pool);
2453
- poolPtr = 0;
2454
- }
2455
- return rnds8Pool.slice(poolPtr, poolPtr += 16);
2456
- }
2457
-
2458
- // ../../node_modules/uuid/dist/esm/native.js
2459
- import { randomUUID } from "crypto";
2460
- var native_default = { randomUUID };
2461
-
2462
- // ../../node_modules/uuid/dist/esm/v4.js
2463
- function v4(options, buf, offset) {
2464
- if (native_default.randomUUID && !buf && !options) {
2465
- return native_default.randomUUID();
2466
- }
2467
- options = options || {};
2468
- const rnds = options.random ?? options.rng?.() ?? rng();
2469
- if (rnds.length < 16) {
2470
- throw new Error("Random bytes length must be >= 16");
2471
- }
2472
- rnds[6] = rnds[6] & 15 | 64;
2473
- rnds[8] = rnds[8] & 63 | 128;
2474
- if (buf) {
2475
- offset = offset || 0;
2476
- if (offset < 0 || offset + 16 > buf.length) {
2477
- throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
2478
- }
2479
- for (let i = 0; i < 16; ++i) {
2480
- buf[offset + i] = rnds[i];
2481
- }
2482
- return buf;
2483
- }
2484
- return unsafeStringify(rnds);
2485
- }
2486
- var v4_default = v4;
2487
-
2488
- // src/lib/render-jobs.ts
2489
- var jobs = /* @__PURE__ */ new Map();
2490
- var MAX_AGE_MS = 30 * 60 * 1e3;
2491
- var RENDER_TIMING = {
2492
- expectedRange: "15-45 seconds",
2493
- pollDelay: "~10 seconds"
2494
- };
2495
- function createJob(tool) {
2496
- const job = {
2497
- id: v4_default(),
2498
- status: "rendering",
2499
- tool,
2500
- createdAt: Date.now()
2501
- };
2502
- jobs.set(job.id, job);
2503
- return job;
2504
- }
2505
- function completeJob(id, result) {
2506
- const job = jobs.get(id);
2507
- if (job) {
2508
- job.status = "complete";
2509
- job.result = result;
2510
- }
2511
- }
2512
- function failJob(id, error) {
2513
- const job = jobs.get(id);
2514
- if (job) {
2515
- job.status = "failed";
2516
- job.error = error;
2517
- }
2518
- }
2519
- function getJob(id) {
2520
- return jobs.get(id);
2521
- }
2522
- function pruneJobs() {
2523
- const cutoff = Date.now() - MAX_AGE_MS;
2524
- for (const [id, job] of jobs) {
2525
- if (job.createdAt < cutoff) jobs.delete(id);
2526
- }
2527
- }
2528
-
2529
- // src/tools/generate-slideshow.ts
2530
- var slideshowStyleSchema = z18.object({
2531
- preset: z18.enum(["cinematic", "dramatic", "calm", "documentary", "dreamy", "moody"]).optional().describe("Named shortcut applied before per-field overrides. cinematic = defaults; dramatic = big zoom, wider pan, ease-out, stronger vignette; calm = minimal motion, linear, no vignette; documentary = near-static, no pan; dreamy = bright heavy blur-pad, soft vignette, gentle zoom; moody = desaturated low-key blur-pad, strong vignette. Set any other field to override individual knobs on top of the preset."),
2532
- zoom: z18.object({
2533
- from: z18.number().optional().describe("Starting scale for zoom-in / ending scale for zoom-out. Default 1.0."),
2534
- to: z18.number().optional().describe("Ending scale for zoom-in / starting scale for zoom-out. Default 1.3 (cover) / 1.15 (blur-pad).")
2535
- }).optional().describe("Zoom range. Lower 'to' (e.g. 1.05) for near-static cinematography; higher (e.g. 1.4) for dramatic push-in."),
2536
- pan: z18.object({
2537
- range: z18.number().optional().describe("Pan drift as fraction of viewport on each side. Default 0.05 (cover) / 0.03 (blur-pad). 0.08+ feels like sweeping."),
2538
- scale: z18.number().optional().describe("Baseline scale during pan effects. Must exceed 1 + 2\xD7range so edges never show. Default 1.12 / 1.08.")
2539
- }).optional(),
2540
- zoomPan: z18.object({
2541
- from: z18.number().optional(),
2542
- to: z18.number().optional(),
2543
- rangeFraction: z18.number().optional().describe("Multiplier on pan.range for zoom-in-pan-* effects. Default 0.5.")
2544
- }).optional(),
2545
- easing: z18.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).optional().describe("Motion curve. 'ease-in-out' = cinematic default. 'linear' = mechanical/deliberate. 'ease-out' = fast-start slow-finish, good for reveals."),
2546
- blurPad: z18.object({
2547
- blurPx: z18.number().optional().describe("Blur strength on the background layer. Default 40. 60+ for heavy dreamy effect, 20 for just-barely."),
2548
- brightness: z18.number().optional().describe("Background brightness multiplier. Default 0.55 (dimmed). Raise to 0.8+ for a brighter bed; lower for moodier."),
2549
- saturate: z18.number().optional().describe("Background saturation. Default 1.15. Lower (0.6) for desaturated/muted bg; higher for vivid."),
2550
- overscale: z18.number().optional().describe("Background overscale to hide blur edges. Default 1.12."),
2551
- foregroundScale: z18.number().optional().describe("Gentle crop on the contain-fit sharp layer. Default 1.0 (pure contain). 1.2 reduces letterbox by accepting mild crop.")
2552
- }).optional().describe("Styling for the blurred-letterbox layer. Only applies when fit resolves to 'blur-pad' (landscape source in portrait viewport)."),
2553
- vignette: z18.union([
2554
- z18.literal(false),
2555
- z18.object({
2556
- opacity: z18.number().optional().describe("Edge darkness opacity, 0-1. Default 0.35 (cover) / 0.4 (blur-pad)."),
2557
- innerRadiusPercent: z18.number().optional().describe("Where darkening starts, 0 = center, 100 = edge. Default 50-55.")
2558
- })
2559
- ]).optional().describe("Edge darkening for cinematic framing. Pass false to disable."),
2560
- backgroundColor: z18.string().optional().describe("Hex color behind the image frame. Default '#000'."),
2561
- autoBlurPadThreshold: z18.number().optional().describe("When fit='auto', trigger blur-pad if aspectRatio > this value. Default 1.1.")
2562
- }).describe("Creative style overrides. Every field optional - unset inherits defaults.");
2563
- function registerGenerateSlideshowTool(server) {
2564
- server.registerTool(
2565
- "clipform_generate_slideshow",
2566
- {
2567
- title: "Generate Slideshow",
2568
- description: `**Deprecated: prefer clipform_generate_video** which supports both images and video clips.
2569
-
2570
- Generate a slideshow video from images and audio. Creates a 9:16 (720x1280) video with smooth pan/zoom effects (Ken Burns style), eased motion, and crossfade transitions, synced to the audio duration. Uploaded to storage; returns a public URL.
2571
-
2572
- You are the director. Every stylistic choice below is yours - defaults exist for convenience but override anything that fits your creative vision.
2573
-
2574
- ## Workflow
2575
-
2576
- 1. Source images (use clipform_search_media with kind: "image"). Prefer portrait sources for 9:16 output; landscape works too via blur-pad fallback.
2577
- 2. Produce narration audio (clipform_generate_tts).
2578
- 3. Call this tool with images + audio_url + your creative direction.
2579
- 4. Attach the returned public URL to a question via clipform_upload_media with media_type "video".
2580
-
2581
- ## Image framing (per-image)
2582
-
2583
- - **aspect_ratio** (width/height): pass through from the image source so the composition can auto-pick framing. Landscape images with no aspect_ratio will cover-crop and may pixelate.
2584
- - **fit**: 'cover' (fill frame, crop), 'blur-pad' (sharp contained foreground + blurred cover background for landscape sources), 'auto' (default: cover for portrait, blur-pad for landscape > 1.1:1).
2585
- - **style**: per-image creative overrides (see Style Knobs below).
2586
-
2587
- ## Effects
2588
-
2589
- zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-pan-left, zoom-in-pan-right, random, static. Set 'random' or pass random_effects: true to shuffle per image.
2590
-
2591
- ## Transitions
2592
-
2593
- fade (default), slide, wipe. Legacy names (fadeblack, slideleft, etc.) also work. duration is in seconds.
2594
-
2595
- ## Style presets (fastest path)
2596
-
2597
- Pick one as a starting point via \`style.preset\` (per-image) or \`default_style.preset\` (global). Override individual knobs on top.
2598
-
2599
- - **cinematic** - the default feel. Smooth ease-in-out, zoom 1.0\u21921.3, subtle pan, subtle vignette.
2600
- - **dramatic** - zoom 1.0\u21921.4, ease-out, wider pan (0.07), strong vignette. Good for big-reveal content, competitions, climactic moments.
2601
- - **calm** - zoom 1.0\u21921.08, linear easing, no vignette. Reflective topics, wellness, educational explainers.
2602
- - **documentary** - near-static (zoom 1.0\u21921.05, tiny pan, no vignette). Talking-heads-style context shots, historical photos.
2603
- - **dreamy** - heavy blur-pad (blurPx 60, brightness 0.75), soft vignette. Aspirational, romantic, nostalgic.
2604
- - **moody** - desaturated blur-pad (brightness 0.35, saturate 0.7), strong vignette, dark wrapper color. Crime, mystery, somber topics.
2605
-
2606
- Per-image preset wins over default_style preset. Per-field overrides (zoom, pan, etc.) merge on top of whichever preset is active.
2607
-
2608
- ## Style Knobs (per-image via images[i].style, or global via default_style)
2609
-
2610
- - **zoom**: { from, to } - push-in/pull-out range. Cinematic default 1.0\u21921.3. Drop to 1.0\u21921.05 for near-static; push to 1.0\u21921.4 for dramatic.
2611
- - **pan**: { range, scale } - drift magnitude. 0.03 subtle, 0.05 default, 0.08+ sweeping.
2612
- - **easing**: 'ease-in-out' (default, cinematic) | 'ease-in' (accelerate) | 'ease-out' (decelerate, good for reveals) | 'linear' (mechanical).
2613
- - **blurPad**: { blurPx, brightness, saturate, overscale, foregroundScale } - only applies when blur-pad fit is active. Heavier blurPx (60) + lower brightness (0.35) for moody; foregroundScale: 1.2 to reduce letterbox at the cost of mild crop.
2614
- - **vignette**: false to disable, or { opacity, innerRadiusPercent }. Stronger vignette (opacity 0.6) for dramatic focus; disable for bright, flat looks.
2615
- - **backgroundColor**: hex color shown behind the image frame (default '#000').
2616
- - **autoBlurPadThreshold**: when fit='auto', images wider than this ratio get blur-pad. Default 1.1.
2617
-
2618
- ## Global options
2619
-
2620
- - **default_style**: style applied to every image unless the image overrides per-field. Per-image style merges over default_style at the field level.
2621
- - **background_color**: viewport wrapper color, visible during cross-fades.
2622
- - **random_effects**: shuffles effects across images when true.
2623
-
2624
- ## Creative guidance
2625
-
2626
- - For the same 5-image quiz slideshow, vary the effects (one zoom-in on the opener, pan-left on a cityscape, zoom-out on the closer) rather than random_effects: true, unless you want the dice-roll.
2627
- - Quiet/reflective topics: slow easing ('linear'), tight zoom (1.0\u21921.08), no vignette.
2628
- - Energetic topics: punchy zoom (1.0\u21921.35), ease-out, wider pan.
2629
- - Mixed orientation sets: rely on fit: 'auto' + pass aspect_ratio per image. The composition will cover-crop portraits and blur-pad landscapes without more work.`,
2630
- inputSchema: {
2631
- images: z18.array(
2632
- z18.object({
2633
- url: z18.string().url().describe("Image URL"),
2634
- effect: z18.string().optional().describe("Ken Burns effect name (see description). Pass 'random' to shuffle this image only."),
2635
- aspect_ratio: z18.number().positive().optional().describe("Source image width / height. Enables auto blur-pad for landscape images in the 9:16 viewport."),
2636
- fit: z18.enum(["cover", "blur-pad", "auto"]).optional().describe("Framing mode. 'auto' (default) picks cover for portrait, blur-pad for landscape."),
2637
- style: slideshowStyleSchema.optional()
2638
- })
2639
- ).min(1).max(20).describe("Images for the slideshow (1-20)"),
2640
- audio_url: z18.string().url().describe("URL of the audio track (mp3/wav). Slideshow duration matches audio duration."),
2641
- random_effects: z18.boolean().optional().default(true).describe("Shuffle effects across all images (default: true). Set false when specifying effects explicitly per-image."),
2642
- transition: z18.object({
2643
- type: z18.string().optional().default("fade").describe("Transition type: fade (default), slide, wipe."),
2644
- duration: z18.number().optional().default(1).describe("Transition duration in seconds (default: 1).")
2645
- }).optional().describe("Transition between images."),
2646
- default_style: slideshowStyleSchema.optional().describe("Style applied to every image unless overridden per-image."),
2647
- background_color: z18.string().optional().describe("Viewport background color behind all images (default '#000').")
2648
- },
2649
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
2650
- },
2651
- async ({ images, audio_url, random_effects, transition, default_style, background_color }) => {
2652
- const workspace_id = process.env.MCP_WORKSPACE_ID;
2653
- const job = createJob("clipform_generate_slideshow");
2654
- callInternalApi("/internal/slideshow", {
2655
- body: {
2656
- images,
2657
- audio_url,
2658
- random_effects: random_effects ?? true,
2659
- transition: transition ?? { type: "fade", duration: 1 },
2660
- default_style,
2661
- background_color,
2662
- workspace_id
2663
- }
2664
- }).then((result) => {
2665
- if (!result.ok) {
2666
- failJob(job.id, result.error);
2667
- } else {
2668
- completeJob(job.id, result.data);
2669
- }
2670
- }).catch((err) => {
2671
- failJob(job.id, err instanceof Error ? err.message : String(err));
2672
- });
2673
- return textResult(
2674
- [
2675
- `Slideshow render started (${images.length} image${images.length > 1 ? "s" : ""}).`,
2676
- ``,
2677
- `Job ID: ${job.id}`,
2678
- ``,
2679
- `Renders typically take ${RENDER_TIMING.expectedRange}. Use clipform_check_render with this job ID to check status.`
2680
- ].join("\n")
2681
- );
2682
- }
2683
- );
2684
- }
2685
-
2686
- // src/tools/search-media.ts
2687
- import { z as z19 } from "zod";
2688
- function registerSearchMediaTool(server) {
2689
- server.registerTool(
2690
- "clipform_search_media",
2691
- {
2692
- title: "Search Media",
2693
- description: `Search royalty-free images or stock video clips to attach to a node. Routes across Pexels, Unsplash, Pixabay, Wikimedia, NASA, and iNaturalist for images; Pexels and Pixabay for video. The router picks relevant providers based on the topic (space \u2192 NASA, nature \u2192 iNaturalist, general \u2192 stock libraries). Returns URLs you can pass to clipform_upload_node_media or clipform_generate_slideshow.`,
2694
- inputSchema: {
2695
- query: z19.string().describe("What to search for (e.g. 'african lion', 'saturn rings', 'city timelapse')"),
2696
- kind: z19.enum(["image", "video"]).describe("image = stock photos, video = stock clips"),
2697
- count: z19.number().min(1).max(20).default(6).optional().describe("Max results per provider (default 6 for image, 3 for video)")
2698
- },
2699
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
2700
- },
2701
- async ({ query, kind, count }) => {
2702
- const result = await callInternalApi("/internal/search-media", {
2703
- body: { query, kind, count }
2704
- });
2705
- if (!result.ok) return errorResult(result.error);
2706
- const results = result.data.results;
2707
- if (!results.length) return textResult(`No ${kind}s found for "${query}".`);
2708
- const lines = [`Found ${results.length} ${kind}s for "${query}":
2709
- `];
2710
- for (const item of results.slice(0, 15)) {
2711
- lines.push(`- [${item.source}] ${item.title}`);
2712
- lines.push(` URL: ${item.url}`);
2713
- if (item.width && item.height) lines.push(` Size: ${item.width}x${item.height}`);
2714
- if (kind === "video" && item.duration) lines.push(` Duration: ${item.duration}s`);
2715
- if (item.attribution) lines.push(` Attribution: ${item.attribution}`);
2716
- if (item.license) lines.push(` License: ${item.license}`);
2717
- lines.push("");
2718
- }
2719
- return textResult(lines.join("\n"));
2720
- }
2721
- );
2722
- }
2723
-
2724
- // src/tools/render-composition.ts
2725
- import { z as z20 } from "zod";
2726
- function registerRenderCompositionTool(server) {
2727
- server.registerTool(
2728
- "clipform_render_composition",
2729
- {
2730
- title: "Render Composition",
2731
- description: `Render a Remotion composition to MP4, PNG, or GIF. MP4 renders use AWS Lambda in production for fast, scalable rendering. PNG and GIF render locally.
2732
-
2733
- Output formats:
2734
- - mp4: Video file (H.264 codec, best for social media)
2735
- - png: Still image (single frame)
2736
- - gif: Animated GIF (looping)
2737
-
2738
- For narrated quiz slideshows, prefer clipform_generate_slideshow which handles the full workflow (focal point detection, audio sync, storage upload). Use this tool for custom compositions like ScorecardQuiz, ShortFormQuiz, or PresenterDirected.`,
2739
- inputSchema: {
2740
- compositionId: z20.string().describe("The composition ID (e.g. 'ScorecardQuiz', 'ShortFormQuiz', 'PresenterDirected')"),
2741
- outputFormat: z20.enum(["mp4", "png", "gif"]).default("mp4").describe("Output format (default: mp4)"),
2742
- inputProps: z20.record(z20.unknown()).optional().describe("Props object matching the composition's expected schema")
2743
- },
2744
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
2745
- },
2746
- async ({ compositionId, outputFormat, inputProps }) => {
2747
- const job = createJob("clipform_render_composition");
2748
- callInternalApi("/internal/render", {
2749
- body: {
2750
- compositionId,
2751
- outputFormat,
2752
- inputProps: inputProps ?? {}
2753
- }
2754
- }).then((result) => {
2755
- if (!result.ok) {
2756
- failJob(job.id, result.error);
2757
- } else {
2758
- completeJob(job.id, result.data);
2759
- }
2760
- }).catch((err) => {
2761
- failJob(job.id, err instanceof Error ? err.message : String(err));
2762
- });
2763
- return textResult(
2764
- [
2765
- `Render started.`,
2766
- ``,
2767
- `Composition: ${compositionId}`,
2768
- `Format: ${outputFormat}`,
2769
- `Job ID: ${job.id}`,
2770
- ``,
2771
- `Renders typically take ${RENDER_TIMING.expectedRange}. Use clipform_check_render with this job ID to check status.`
2772
- ].join("\n")
2773
- );
2774
- }
2775
- );
2776
- }
2777
-
2778
- // src/tools/search-music.ts
2779
- import { z as z21 } from "zod";
2780
- function registerSearchMusicTool(server) {
2781
- server.registerTool(
2782
- "clipform_search_music",
2783
- {
2784
- title: "Search Music",
2785
- description: `Search for royalty-free music tracks and ambient sounds via Jamendo and Freesound. Returns download URLs for background music, quiz soundtracks, or ambient audio.`,
2786
- inputSchema: {
2787
- query: z21.string().describe("What to search for (e.g. 'upbeat quiz background', 'calm ambient', 'playful pizzicato')"),
2788
- count: z21.number().min(1).max(10).default(5).optional().describe("Max results (default: 5)"),
2789
- instrumentalOnly: z21.boolean().optional().default(true).describe("Only instrumental tracks (default: true)"),
2790
- minDuration: z21.number().optional().describe("Minimum duration in seconds"),
2791
- maxDuration: z21.number().optional().describe("Maximum duration in seconds"),
2792
- tags: z21.array(z21.string()).optional().describe("Genre/mood tags to filter by")
2793
- },
2794
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
2795
- },
2796
- async ({ query, count, instrumentalOnly, minDuration, maxDuration, tags }) => {
2797
- const result = await callInternalApi("/internal/search-music", {
2798
- body: { query, count, instrumentalOnly, minDuration, maxDuration, tags }
2799
- });
2800
- if (!result.ok) return errorResult(result.error);
2801
- const results = result.data.results;
2802
- if (!results.length) return textResult(`No music found for "${query}".`);
2803
- const lines = [`Found ${results.length} tracks for "${query}":
2804
- `];
2805
- for (const item of results) {
2806
- lines.push(`- ${item.title} by ${item.artist}`);
2807
- lines.push(` URL: ${item.url}`);
2808
- if (item.duration) lines.push(` Duration: ${item.duration}s`);
2809
- if (item.source) lines.push(` Source: ${item.source}`);
2810
- if (item.license) lines.push(` License: ${item.license}`);
2811
- lines.push("");
2812
- }
2813
- return textResult(lines.join("\n"));
2814
- }
2815
- );
2816
- }
2817
-
2818
- // src/tools/list-compositions.ts
2819
- function registerListCompositionsTool(server) {
2820
- server.registerTool(
2821
- "clipform_list_compositions",
2822
- {
2823
- title: "List Compositions",
2824
- description: `List all available Remotion compositions and their expected props schemas. Use clipform_render_composition to render them.`,
2825
- inputSchema: {},
2826
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
2827
- },
2828
- async () => {
2829
- const result = await callInternalApi("/internal/compositions", {
2830
- method: "GET"
2831
- });
2832
- if (!result.ok) {
2833
- if (result.status === 0 || result.status === 404) {
2834
- return errorResult("Remotion compositions are not available (API server may not be running). Start the API with 'npm run dev --workspace=apps/api'.");
2835
- }
2836
- return errorResult(result.error);
2837
- }
2838
- const compositions = result.data.compositions;
2839
- if (!compositions.length) return textResult("No compositions found.");
2840
- const lines = [`Available compositions (${compositions.length}):
2841
- `];
2842
- for (const comp of compositions) {
2843
- lines.push(`## ${comp.id}`);
2844
- lines.push(` Duration: ${comp.durationInFrames} frames @ ${comp.fps}fps (${(comp.durationInFrames / comp.fps).toFixed(1)}s)`);
2845
- lines.push(` Size: ${comp.width}x${comp.height}`);
2846
- if (comp.defaultProps) {
2847
- lines.push(` Default props: ${JSON.stringify(comp.defaultProps, null, 2)}`);
2848
- }
2849
- lines.push("");
2850
- }
2851
- return textResult(lines.join("\n"));
2852
- }
2853
- );
2854
- }
2855
-
2856
- // src/tools/list-assets.ts
2857
- import { z as z22 } from "zod";
2858
- function registerListAssetsTool(server) {
2859
- server.registerTool(
2860
- "clipform_list_assets",
2861
- {
2862
- title: "List Assets",
2863
- description: `List available creative assets (sound effects, animations, fonts). Use this to discover what assets are available for use in compositions.`,
2864
- inputSchema: {
2865
- type: z22.enum(["sfx", "animation", "font", "all"]).default("all").optional().describe("Asset type to list (default: all)")
2866
- },
2867
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
2868
- },
2869
- async ({ type }) => {
2870
- const result = await callInternalApi("/internal/assets", {
2871
- method: "GET",
2872
- params: { type: type ?? "all" }
2873
- });
2874
- if (!result.ok) return errorResult(result.error);
2875
- const data = result.data;
2876
- const lines = [];
2877
- if (data.sfx?.length) {
2878
- lines.push(`## Sound Effects (${data.sfx.length})
2879
- `);
2880
- for (const sfx of data.sfx) {
2881
- lines.push(`- ${sfx.name}: ${sfx.description || sfx.path}`);
2882
- }
2883
- lines.push("");
2884
- }
2885
- if (data.animations?.length) {
2886
- lines.push(`## Animations (${data.animations.length})
2887
- `);
2888
- for (const anim of data.animations) {
2889
- lines.push(`- ${anim.name}: ${anim.description || anim.path}`);
2890
- }
2891
- lines.push("");
2892
- }
2893
- if (data.fonts?.length) {
2894
- lines.push(`## Fonts (${data.fonts.length})
2895
- `);
2896
- for (const font of data.fonts) {
2897
- lines.push(`- ${font.name}: weights ${font.weights?.join(", ") || "default"}`);
2898
- }
2899
- lines.push("");
2900
- }
2901
- return textResult(lines.length ? lines.join("\n") : "No assets found.");
2902
- }
2903
- );
2904
- }
2905
-
2906
- // src/tools/generate-video.ts
2907
- import { z as z23 } from "zod";
2908
- var slideshowStyleSchema2 = z23.object({
2909
- preset: z23.enum(["cinematic", "dramatic", "calm", "documentary", "dreamy", "moody"]).optional(),
2910
- zoom: z23.object({ from: z23.number().optional(), to: z23.number().optional() }).optional(),
2911
- pan: z23.object({ range: z23.number().optional(), scale: z23.number().optional() }).optional(),
2912
- zoomPan: z23.object({
2913
- from: z23.number().optional(),
2914
- to: z23.number().optional(),
2915
- rangeFraction: z23.number().optional()
2916
- }).optional(),
2917
- easing: z23.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).optional(),
2918
- blurPad: z23.object({
2919
- blurPx: z23.number().optional(),
2920
- brightness: z23.number().optional(),
2921
- saturate: z23.number().optional(),
2922
- overscale: z23.number().optional(),
2923
- foregroundScale: z23.number().optional()
2924
- }).optional(),
2925
- vignette: z23.union([
2926
- z23.literal(false),
2927
- z23.object({ opacity: z23.number().optional(), innerRadiusPercent: z23.number().optional() })
2928
- ]).optional(),
2929
- backgroundColor: z23.string().optional(),
2930
- autoBlurPadThreshold: z23.number().optional()
2931
- });
2932
- function registerGenerateVideoTool(server) {
2933
- server.registerTool(
2934
- "clipform_generate_video",
2935
- {
2936
- title: "Generate Video",
2937
- description: `Generate a video from images, video clips, or a mix of both, synced to audio. Creates a 9:16 (720x1280) video with transitions, uploaded to storage. Returns a public URL.
2938
-
2939
- ## Workflow
2940
-
2941
- 1. Source media with clipform_search_media (kind: "image" or "video")
2942
- 2. Source audio with clipform_generate_tts (narration) or clipform_search_music (background)
2943
- 3. Call this tool with items + audio_url
2944
- 4. Attach the returned URL to a node via clipform_upload_node_media with media_type "video"
2945
-
2946
- ## Items
2947
-
2948
- Each item is an image or video clip:
2949
- - **type: "image"** - still image with Ken Burns motion (pan/zoom). Supports effect, fit, and style overrides.
2950
- - **type: "video"** - video clip, cover-cropped to fill 9:16 frame. Audio muted by default (set volume: 1 to mix in). Use start_from to trim.
2951
-
2952
- ## Style presets (image items)
2953
-
2954
- Pick one via default_style.preset (all images) or per-image style.preset:
2955
- - **cinematic** (default) - smooth ease-in-out, subtle zoom/pan, subtle vignette
2956
- - **dramatic** - big zoom, wide pan, ease-out, strong vignette. Reveals, climactic moments.
2957
- - **calm** - minimal motion, linear easing, no vignette. Educational, reflective.
2958
- - **documentary** - near-static, tiny pan, no vignette. Historical photos, talking-heads context.
2959
- - **dreamy** - heavy blur-pad, soft vignette. Aspirational, nostalgic.
2960
- - **moody** - desaturated, dark, strong vignette. Mystery, somber.
2961
-
2962
- ## Effects (image items)
2963
-
2964
- zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-pan-left, zoom-in-pan-right, random, static.
2965
- Set per-image or use random_effects: true to shuffle.
2966
-
2967
- ## Transitions
2968
-
2969
- fade (default), slide, wipe, none. Duration in seconds.
2970
-
2971
- ## Duration
2972
-
2973
- Matches audio when audio_url is provided. Use duration_seconds for videos without audio.`,
2974
- inputSchema: {
2975
- items: z23.array(
2976
- z23.object({
2977
- type: z23.enum(["image", "video"]).describe("'image' for still images with Ken Burns effects, 'video' for video clips"),
2978
- url: z23.string().url().describe("Media URL"),
2979
- effect: z23.string().optional().describe("Ken Burns effect (image only): zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-pan-left, zoom-in-pan-right, random, static"),
2980
- aspect_ratio: z23.number().positive().optional().describe("Image width/height ratio (image only). Enables auto blur-pad for landscape images."),
2981
- fit: z23.enum(["cover", "blur-pad", "auto"]).optional().describe("Framing mode (image only). auto = cover for portrait, blur-pad for landscape."),
2982
- style: slideshowStyleSchema2.optional().describe("Creative style overrides (image only)"),
2983
- start_from: z23.number().min(0).optional().describe("Start time in seconds (video only). Trims the clip."),
2984
- volume: z23.number().min(0).max(1).optional().describe("Clip audio volume 0-1 (video only, default 0 = muted)")
2985
- })
2986
- ).min(1).max(20).describe("Media items (images, video clips, or a mix)"),
2987
- audio_url: z23.string().url().optional().describe("Audio track URL. Video duration matches audio duration."),
2988
- duration_seconds: z23.number().positive().optional().describe("Video duration in seconds (required if no audio_url)"),
2989
- random_effects: z23.boolean().optional().default(true).describe("Shuffle Ken Burns effects across image items (default: true)"),
2990
- transition: z23.object({
2991
- type: z23.string().optional().default("fade").describe("Transition: fade (default), slide, wipe, none"),
2992
- duration: z23.number().optional().default(1).describe("Transition duration in seconds (default: 1)")
2993
- }).optional(),
2994
- default_style: slideshowStyleSchema2.optional().describe("Style applied to all image items unless overridden per-item"),
2995
- background_color: z23.string().optional().describe("Background color (default '#000')")
2996
- },
2997
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
2998
- },
2999
- async ({ items, audio_url, duration_seconds, random_effects, transition, default_style, background_color }) => {
3000
- const workspace_id = process.env.MCP_WORKSPACE_ID;
3001
- const job = createJob("clipform_generate_video");
3002
- callInternalApi("/internal/generate-video", {
3003
- body: {
3004
- items,
3005
- audio_url,
3006
- duration_seconds,
3007
- random_effects: random_effects ?? true,
3008
- transition: transition ?? { type: "fade", duration: 1 },
3009
- default_style,
3010
- background_color,
3011
- workspace_id
3012
- }
3013
- }).then((result) => {
3014
- if (!result.ok) {
3015
- failJob(job.id, result.error);
3016
- } else {
3017
- completeJob(job.id, result.data);
3018
- }
3019
- }).catch((err) => {
3020
- failJob(job.id, err instanceof Error ? err.message : String(err));
3021
- });
3022
- return textResult(
3023
- [
3024
- `Render started (${items.length} item${items.length > 1 ? "s" : ""}).`,
3025
- ``,
3026
- `Job ID: ${job.id}`,
3027
- ``,
3028
- `Renders typically take ${RENDER_TIMING.expectedRange}. Use clipform_check_render with this job ID to check status.`
3029
- ].join("\n")
3030
- );
3031
- }
3032
- );
3033
- }
3034
-
3035
- // src/tools/check-render.ts
3036
- import { z as z24 } from "zod";
3037
- function registerCheckRenderTool(server) {
3038
- server.registerTool(
3039
- "clipform_check_render",
3040
- {
3041
- title: "Check Render Status",
3042
- description: `Check the status of a render job started by clipform_generate_video, clipform_generate_slideshow, or clipform_render_composition.
3043
-
3044
- Returns the current status and, when complete, the output URL. If still rendering, wait ${RENDER_TIMING.pollDelay} before checking again.`,
3045
- inputSchema: {
3046
- job_id: z24.string().uuid().describe("The job ID returned by the render tool")
3047
- },
3048
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
3049
- },
3050
- async ({ job_id }) => {
3051
- pruneJobs();
3052
- const job = getJob(job_id);
3053
- if (!job) {
3054
- return errorResult(`No render job found with ID ${job_id}. Jobs expire after 30 minutes.`);
3055
- }
3056
- if (job.status === "rendering") {
3057
- const elapsed = Math.round((Date.now() - job.createdAt) / 1e3);
3058
- return textResult(
3059
- [
3060
- `Status: rendering (${elapsed}s elapsed)`,
3061
- `Tool: ${job.tool}`,
3062
- ``,
3063
- `Still in progress. Check again in ${RENDER_TIMING.pollDelay}.`
3064
- ].join("\n")
3065
- );
3066
- }
3067
- if (job.status === "failed") {
3068
- return errorResult(`Render failed: ${job.error}`);
3069
- }
3070
- const data = job.result;
3071
- return textResult(
3072
- [
3073
- `Status: complete`,
3074
- `Tool: ${job.tool}`,
3075
- ``,
3076
- ...data.public_url ? [`Public URL: ${data.public_url}`] : [],
3077
- ...data.storage_path ? [`Storage path: ${data.storage_path}`] : [],
3078
- ...data.duration_seconds ? [`Duration: ${data.duration_seconds}s`] : [],
3079
- ...data.outputPath ? [`Output: ${data.outputPath}`] : [],
3080
- ...data.format ? [`Format: ${data.format}`] : [],
3081
- ``,
3082
- `Use clipform_upload_node_media with the public URL to attach this video to a node.`
3083
- ].join("\n")
3084
- );
3085
- }
3086
- );
3087
- }
3088
-
3089
- // src/lib/session-context.ts
3090
- async function getSessionContext() {
3091
- const result = await callApi("/me", { method: "GET" });
3092
- if (!result.ok) return "";
3093
- const me = result.data;
3094
- const plan = me.plan;
3095
- const lines = [];
3096
- lines.push("## Your Session");
3097
- if (me.auth_mode === "oauth") {
3098
- lines.push(`Auth: OAuth (connected)`);
3099
- lines.push(`Workspace: ${me.workspace?.name ?? "Unknown"} (${me.workspace?.id ?? "?"})`);
3100
- lines.push(`User: ${me.user_id ?? "?"}`);
3101
- lines.push(`Company: ${me.company_id ?? "none"}`);
3102
- } else {
3103
- lines.push("Auth: anonymous (not connected to a Clipform account)");
3104
- if (me.workspace) {
3105
- lines.push(`Workspace: ${me.workspace.name} (${me.workspace.id})`);
3106
- }
3107
- }
3108
- lines.push(`Plan: ${plan.name} (tier ${plan.tier})`);
3109
- if (plan.node_limit !== null) {
3110
- lines.push(`Question limit: ${plan.node_limit} per form`);
3111
- } else {
3112
- lines.push("Questions: unlimited");
3113
- }
3114
- if (plan.custom_theme === false) {
3115
- lines.push("Custom themes: not available on this plan");
3116
- }
3117
- if (me.auth_mode !== "oauth") {
3118
- lines.push(`
3119
- To unlock your full plan: connect your Clipform account in Settings > Connectors > ${BUSINESS.urls.mcp}`);
3120
- }
3121
- return lines.join("\n");
3122
- }
3123
-
3124
- // src/prompts.ts
3125
- function registerPrompts(server) {
3126
- server.registerPrompt(
3127
- "create-quiz",
3128
- {
3129
- title: "Create a Quiz",
3130
- description: "Build a scored knowledge quiz with narrated video questions"
3131
- },
3132
- async () => {
3133
- const sessionContext = await getSessionContext();
3134
- return {
3135
- messages: [
3136
- {
3137
- role: "user",
3138
- content: {
3139
- type: "text",
3140
- text: "I want to create a quiz. What's the best approach?"
3141
- }
3142
- },
3143
- {
3144
- role: "assistant",
3145
- content: {
3146
- type: "text",
3147
- text: `${sessionContext ? sessionContext + "\n\n" : ""}Here's how to build a great quiz with Clipform. Read the quiz writing guide (clipform://guides/quiz) for detailed craft knowledge on question design, difficulty curves, and narration style.
3148
-
3149
- ## Workflow
3150
-
3151
- 1. **Research** the topic - find surprising facts, common misconceptions, myth-busters
3152
- 2. **Write questions** - follow the difficulty curve (easy start, hard middle, satisfying end). Target 5-8 questions.
3153
- 3. **Create the form** with clipform_create_form:
3154
- - show_step_counter: true
3155
- - disable_back_navigation: true
3156
- 4. **Add questions** with clipform_add_node (type: "choice"):
3157
- - config: { choice: { show_answer_feedback: true } }
3158
- - randomise_options: true in config
3159
- - score: 1 on correct option, score: 0 on wrong
3160
- - 3-4 wrong answers per question
3161
- 5. **Generate narration** with clipform_generate_tts for each question. Tease the question - do NOT reveal the answer or read options aloud. Keep each narration 5-15 seconds.
3162
- 6. **Build video** for each question:
3163
- - clipform_search_media (kind: "image") - 3 images per question
3164
- - clipform_generate_video - creates Ken Burns video synced to audio
3165
- 7. **Attach media** with clipform_upload_node_media. Include captions, set show_captions: true.
3166
- 8. **Update end screen** with clipform_update_node - EVERY quiz must have:
3167
- - show_score: true, icon: "trophy"
3168
- - show_share_button: true (drives virality)
3169
- - cta_type: "restart", cta_text: a short challenge like "Beat your score?" or "Try again?"
3170
- - score_ranges with personalised title + message per tier. Write these in the quiz's voice - short, punchy, and specific to the topic (not generic "Good job!"). Example for a geography quiz:
3171
- \`\`\`json
3172
- { "min": 0, "max": 2, "title": "Lost Tourist", "message": "You might need a map - and a compass." },
3173
- { "min": 3, "max": 5, "title": "Frequent Flyer", "message": "Not bad! You know your way around." },
3174
- { "min": 6, "max": 8, "title": "World Explorer", "message": "Impressive - you really know your stuff." }
3175
- \`\`\`
3176
- 9. **Publish** with clipform_update_form
3177
- 10. **Tag the form** - pass tags: one format (quiz/survey/interview/feedback/lead-gen), one genre (trivia/personality/nps/poll/testimonial), and 2-3 topic words
3178
- 11. **Log** with clipform_log_generation (sources, images, attributions)
3179
-
3180
- ## Before building, ask
3181
-
3182
- 1. How many questions?
3183
- 2. Media style: text only, still images, or slideshow video with narration?
3184
- 3. Any topic or style preferences?`
3185
- }
3186
- }
3187
- ]
3188
- };
3189
- }
3190
- );
3191
- server.registerPrompt(
3192
- "create-personality-quiz",
3193
- {
3194
- title: "Create a Personality Quiz",
3195
- description: "Build a 'Which X are you?' personality quiz with category-based scoring and outcome screens"
3196
- },
3197
- async () => {
3198
- const sessionContext = await getSessionContext();
3199
- return {
3200
- messages: [
3201
- {
3202
- role: "user",
3203
- content: {
3204
- type: "text",
3205
- text: "I want to create a personality quiz. What's the best approach?"
3206
- }
3207
- },
3208
- {
3209
- role: "assistant",
3210
- content: {
3211
- type: "text",
3212
- text: `${sessionContext ? sessionContext + "\n\n" : ""}Here's how to build a personality quiz with Clipform. Read the personality quiz guide (clipform://guides/personality-quiz) for craft knowledge on category design, option weighting, and outcome writing.
3213
-
3214
- ## How it differs from a knowledge quiz
3215
-
3216
- There are NO correct answers. Each option maps to one or more outcome categories via \`scores\` (not \`score\`). The winning category at the end determines which result screen the respondent sees.
3217
-
3218
- ## Workflow
3219
-
3220
- 1. **Define 3-5 outcome categories** - these are the "personalities" (e.g. "Creative", "Analytical", "Leader", "Collaborator"). More than 5 gets muddy.
3221
- 2. **Write questions** - each question should feel revealing but fun. Target 5-8 questions.
3222
- 3. **Create the form** with clipform_create_form:
3223
- - show_step_counter: true
3224
- - disable_back_navigation: true
3225
- 4. **Add questions** with clipform_add_node (type: "choice"):
3226
- - config: { choice: { show_answer_feedback: false } } (no right/wrong!)
3227
- - Do NOT set randomise_options (option order matters for personality quizzes - lead with the most appealing)
3228
- - Each option gets \`scores: { "CategoryA": 2, "CategoryB": 1 }\` - weight towards relevant categories
3229
- - Every option should score in at least one category (no dead options)
3230
- 5. **Generate narration** with clipform_generate_tts - conversational, reflective tone. "What does this say about you?" not "Do you know the answer?"
3231
- 6. **Build video** + **attach media** (same as knowledge quiz workflow)
3232
- 7. **Update end screen** with clipform_update_node - EVERY personality quiz must have:
3233
- - show_score: false, icon: "star"
3234
- - show_share_button: true (personality results are inherently shareable - "I got X, what did you get?")
3235
- - cta_type: "restart", cta_text: "Find out again?" or "Take it again?"
3236
- - scoring_results (NOT score_ranges) with a result per category. Write the title as an identity reveal ("You're a Creative!") and the message as a short, flattering description that makes people want to share it. Be specific to the quiz theme, not generic. Example:
3237
- \`\`\`json
3238
- {
3239
- "show_score": false,
3240
- "icon": "star",
3241
- "show_share_button": true,
3242
- "cta_type": "restart",
3243
- "cta_text": "Retake quiz",
3244
- "scoring_results": [
3245
- { "category": "Creative", "title": "You're a Creative!", "message": "You see the world through colour and possibility. Where others see problems, you see raw material." },
3246
- { "category": "Analytical", "title": "You're an Analyst!", "message": "You don't guess - you figure it out. Your superpower is turning chaos into clarity." }
3247
- ]
3248
- }
3249
- \`\`\`
3250
- 8. **Publish** with clipform_update_form
3251
- 9. **Tag the form** - pass tags: one format (quiz/survey/interview/feedback/lead-gen), one genre (trivia/personality/nps/poll/testimonial), and 2-3 topic words
3252
- 10. **Log** with clipform_log_generation
3253
-
3254
- ## Before building, ask
3255
-
3256
- 1. What are the possible outcomes/personalities? (3-5 categories)
3257
- 2. What's the theme? ("Which city are you?", "What's your work style?", "Which character are you?")
3258
- 3. Media style: text only, still images, or slideshow video with narration?`
3259
- }
3260
- }
3261
- ]
3262
- };
3263
- }
3264
- );
3265
- server.registerPrompt(
3266
- "create-interview",
3267
- {
3268
- title: "Create an Interview",
3269
- description: "Build a form to collect testimonials, case studies, async video interviews, or journalist responses"
3270
- },
3271
- async () => {
3272
- const sessionContext = await getSessionContext();
3273
- return {
3274
- messages: [
3275
- {
3276
- role: "user",
3277
- content: {
3278
- type: "text",
3279
- text: "I want to collect responses or testimonials from people. What's the best approach?"
3280
- }
3281
- },
3282
- {
3283
- role: "assistant",
3284
- content: {
3285
- type: "text",
3286
- text: `${sessionContext ? sessionContext + "\n\n" : ""}Here's how to build an interview or testimonial form with Clipform. Read the interview guide (clipform://guides/interview) for detailed craft knowledge on question design and pacing.
3287
-
3288
- ## Workflow
3289
-
3290
- 1. **Identify the ask** - what do you need from respondents? Testimonial, case study, expert comment, job application?
3291
- 2. **Create the form** with clipform_create_form:
3292
- - show_step_counter: true
3293
- - disable_back_navigation: false
3294
- 3. **Add a warm-up question** - something easy: "Tell us your name and role" (type: "open")
3295
- 4. **Add core questions** (type: "open") - 2-3 max, one topic per question. Enable text + audio + video responses.
3296
- 5. **Add contact collection** (type: "contact") - first name + email minimum
3297
- 6. **Add consent** if needed - "I agree that my response may be used in [context]"
3298
- 7. **Update end screen** - set expectations: "Thanks! We'll be in touch."
3299
- 8. **Optional: add narration** - warm, inviting tone. "We'd love to hear your story..."
3300
- 9. **Publish** with clipform_update_form
3301
- 10. **Tag the form** - pass tags: one format (quiz/survey/interview/feedback/lead-gen), one genre (trivia/personality/nps/poll/testimonial), and 2-3 topic words
3302
-
3303
- ## Before building, ask
3304
-
3305
- 1. What are you collecting? (testimonial, case study, interview, application)
3306
- 2. Should respondents reply with video, audio, text, or all three?
3307
- 3. Do you need a consent statement?`
3308
- }
3309
- }
3310
- ]
3311
- };
3312
- }
3313
- );
3314
- server.registerPrompt(
3315
- "create-survey",
3316
- {
3317
- title: "Create a Survey",
3318
- description: "Build a feedback survey, NPS form, or research questionnaire"
3319
- },
3320
- async () => {
3321
- const sessionContext = await getSessionContext();
3322
- return {
3323
- messages: [
3324
- {
3325
- role: "user",
3326
- content: {
3327
- type: "text",
3328
- text: "I want to collect feedback or run a survey. What's the best approach?"
3329
- }
3330
- },
3331
- {
3332
- role: "assistant",
3333
- content: {
3334
- type: "text",
3335
- text: `${sessionContext ? sessionContext + "\n\n" : ""}Here's how to build a survey with Clipform. Read the survey guide (clipform://guides/survey) for craft knowledge on question design and reducing respondent fatigue.
3336
-
3337
- ## Workflow
3338
-
3339
- 1. **Define the key metric** - what's the one number you care about? (NPS, satisfaction, likelihood to recommend)
3340
- 2. **Create the form** with clipform_create_form:
3341
- - show_step_counter: true
3342
- - disable_back_navigation: false
3343
- 3. **Add key metric question first** (type: "choice" or "rating") - put it first while attention is highest
3344
- 4. **Add one "why?" follow-up** (type: "open") - "What's the main reason for your score?"
3345
- 5. **Add 2-3 specific questions** (type: "choice") - only ask what you'll act on
3346
- 6. **Contact** (optional) - only if you need to follow up. Many surveys work better anonymous.
3347
- 7. **Update end screen** - "Thanks for your feedback!"
3348
- 8. **Publish** with clipform_update_form
3349
- 9. **Tag the form** - pass tags: one format (quiz/survey/interview/feedback/lead-gen), one genre (trivia/personality/nps/poll/testimonial), and 2-3 topic words
3350
-
3351
- ## Key rule: 5 questions max. Every extra question costs completions.
3352
-
3353
- ## Before building, ask
3354
-
3355
- 1. What feedback are you collecting? (NPS, satisfaction, event feedback, product research)
3356
- 2. Anonymous or identified?
3357
- 3. Any specific areas you want to ask about?`
3358
- }
3359
- }
3360
- ]
3361
- };
3362
- }
3363
- );
3364
- server.registerPrompt(
3365
- "create-funnel",
3366
- {
3367
- title: "Create a Funnel",
3368
- description: "Build a lead qualification funnel or product recommendation quiz with branching logic"
3369
- },
3370
- async () => {
3371
- const sessionContext = await getSessionContext();
3372
- return {
3373
- messages: [
3374
- {
3375
- role: "user",
3376
- content: {
3377
- type: "text",
3378
- text: "I want to qualify leads or recommend products based on answers. What's the best approach?"
3379
- }
3380
- },
3381
- {
3382
- role: "assistant",
3383
- content: {
3384
- type: "text",
3385
- text: `${sessionContext ? sessionContext + "\n\n" : ""}Here's how to build a qualification funnel with Clipform. Read the funnel guide (clipform://guides/funnel) for craft knowledge on branching logic and conversion.
3386
-
3387
- ## Workflow
3388
-
3389
- 1. **Define outcomes** - what segments or recommendations exist? (e.g., Basic/Pro/Enterprise, or product categories)
3390
- 2. **Create the form** with clipform_create_form:
3391
- - show_step_counter: false (funnels feel shorter without it)
3392
- - disable_back_navigation: true (prevents answer shopping that breaks scoring)
3393
- 3. **Add hook question** (type: "choice") - "What best describes you?" or "What are you looking for?" This segments the user.
3394
- 4. **Add qualifying questions** (type: "choice") - 2-3 questions that narrow down the need. Assign scores to each option.
3395
- 5. **Set branching logic** with clipform_set_logic - route based on answers
3396
- 6. **Add contact capture** (type: "contact") - name, email, phone. Place AFTER qualifying questions.
3397
- 7. **Update end screen** with score_ranges for personalised outcomes: "Based on your answers, we recommend..."
3398
- 8. **Publish** with clipform_update_form
3399
- 9. **Tag the form** - pass tags: one format (quiz/survey/interview/feedback/lead-gen), one genre (trivia/personality/nps/poll/testimonial), and 2-3 topic words
3400
-
3401
- ## Key rule: 3-5 questions max. Every extra step loses leads.
3402
-
3403
- ## Before building, ask
3404
-
3405
- 1. What outcomes are you routing to? (products, plans, team members, messages)
3406
- 2. What criteria determine the routing?
3407
- 3. Do you need contact capture?`
3408
- }
3409
- }
3410
- ]
3411
- };
3412
- }
3413
- );
3414
- }
3415
-
3416
- // src/resources.ts
3417
- var WRITING_PRINCIPLES = `## Writing Principles
3418
-
3419
- - **Write for the ear.** Narration is spoken aloud. Short sentences. Natural rhythm.
3420
- - **Research before writing.** Find 2-3 genuinely interesting facts per topic. Generic content doesn't hold attention.
3421
- - **Conversational, not encyclopaedic.** "Here's what's wild about this..." not "The subject is characterized by..."
3422
- - **Cut ruthlessly.** Every word must earn its place.
3423
- - **Never reveal answers in narration.** The user picks from options - narration teases and builds intrigue.
3424
- - **Don't read answer options aloud.** The viewer can see them on screen.
3425
-
3426
- ## Narration Tips
3427
-
3428
- - Tease the topic, don't summarise it
3429
- - Give one interesting fact that makes the user curious
3430
- - 5-15 seconds per question narration
3431
- - If TTS comes back too long, trim the copy and regenerate
3432
-
3433
- ## Research
3434
-
3435
- Search for the specific subject, not generic terms ("komodo dragon habitat" not "reptile"). Cross-reference facts - quiz answers must be correct. Look for the surprising angle: what would make someone say "wait, really?"
3436
-
3437
- For timeless topics (history, geography, science), write from your own knowledge. For anything recent or uncertain, use web search or clipform_search_news. If neither is available, refuse rather than fabricate.`;
3438
- var MEDIA_WORKFLOW = `## Media Workflow
3439
-
3440
- 1. **clipform_generate_tts** - narration audio (returns word-level captions)
3441
- 2. **clipform_search_media** (kind: "image") - find 3 images per question
3442
- 3. **clipform_generate_video** - Ken Burns video from images + audio
3443
- 4. **clipform_upload_node_media** - attach video with captions (set show_captions: true)
3444
-
3445
- **Image selection:**
3446
- - ONLY use URLs from clipform_search_media results
3447
- - Search for the specific subject, not generic terms
3448
- - Pick visually distinct images (different angles, colors, subjects)
3449
- - Landscape images work best for pan effects
3450
-
3451
- **Slideshow defaults:**
3452
- - 3 images per question, random_effects: true
3453
- - transition: { type: "fade", duration: 1 }
3454
- - Auto focal point detection, eased motion, and cinematic vignette are built in`;
3455
- function registerResources(server) {
3456
- server.registerResource(
3457
- "guide-quiz",
3458
- "clipform://guides/quiz",
3459
- {
3460
- description: "Craft knowledge for writing engaging quizzes - difficulty curves, question psychology, narration style, scoring",
3461
- mimeType: "text/markdown"
3462
- },
3463
- async () => ({
3464
- contents: [
3465
- {
3466
- uri: "clipform://guides/quiz",
3467
- mimeType: "text/markdown",
3468
- text: `# Quiz Writing Guide
3469
-
3470
- ## Psychology
3471
-
3472
- Each question is a micro variable-reward event - the same dopamine loop that keeps people watching. Once someone answers 2-3 questions, sunk cost kicks in and they finish. Viewers mentally compete, then want to compare scores.
3473
-
3474
- **Target 50-60% correct.** Too easy = no challenge. Too hard = people feel stupid and won't share.
3475
-
3476
- ## Difficulty Curve
3477
-
3478
- | Position | Difficulty | Purpose |
3479
- |----------|-----------|---------|
3480
- | Q1-Q2 | Easy (80%+ get right) | Build confidence and commitment |
3481
- | Q3-Q5 | Medium | Peak engagement |
3482
- | Q6-Q8 | Hard (include one "gotcha") | The "everyone gets this wrong" moment |
3483
- | Q9-Q10 | One hard, one satisfying medium | End on a smart feeling, not defeat |
3484
-
3485
- ## Question Design
3486
-
3487
- - **Myth-busters**: "Sushi means raw fish - True or False?" (False - it means seasoned rice)
3488
- - **Sounds fake but true**: counterintuitive correct answers make people rewatch
3489
- - **Common misconceptions**: "Capital of Australia?" (not Sydney - Canberra)
3490
- - Under 12-15 words per question for mobile readability
3491
- - Trigger gut reactions, not deep thinking
3492
-
3493
- ## Wrong Answer Generation
3494
-
3495
- For numeric questions (population, speed, weight), scale the real answer by random multipliers (0.3x to 3x) rounded to the same magnitude. Makes wrong answers plausible but clearly different.
3496
-
3497
- ## Narration Style
3498
-
3499
- You're a quiz master, not a question reader. Each question's narration should:
3500
-
3501
- 1. **Tease** - set the scene, build intrigue ("This one catches everyone out")
3502
- 2. **Give context** - one interesting fact that makes the question richer
3503
- 3. **Pose the question** - "So here's the question..."
3504
-
3505
- **Don't say:**
3506
- - "You either know it or you don't" (meaningless filler)
3507
- - "This is a really hard one" on every question (loses impact)
3508
- - "Welcome to my quiz" / "Hey guys" (wastes time, skip to Q1)
3509
-
3510
- ${WRITING_PRINCIPLES}
3511
-
3512
- ${MEDIA_WORKFLOW}`
3513
- }
3514
- ]
3515
- })
3516
- );
3517
- server.registerResource(
3518
- "guide-personality-quiz",
3519
- "clipform://guides/personality-quiz",
3520
- {
3521
- description: "Craft knowledge for building personality quizzes - category design, option weighting, outcome writing, no right/wrong answers",
3522
- mimeType: "text/markdown"
3523
- },
3524
- async () => ({
3525
- contents: [
3526
- {
3527
- uri: "clipform://guides/personality-quiz",
3528
- mimeType: "text/markdown",
3529
- text: `# Personality Quiz Guide
3530
-
3531
- ## How it works
3532
-
3533
- Personality quizzes use **category-based scoring** (the \`scores\` field on options) instead of right/wrong scoring. Each option distributes points across outcome categories. The category with the highest total at the end determines the result.
3534
-
3535
- ## Category Design
3536
-
3537
- - **3-5 categories** is the sweet spot. Fewer feels too binary, more feels random.
3538
- - Categories should be **distinct but equally appealing**. Nobody wants to get the "bad" result.
3539
- - Name them after the outcome, not the trait: "Explorer" not "Adventurous", "The Architect" not "Organised".
3540
-
3541
- ## Option Weighting
3542
-
3543
- Each option scores into one or more categories:
3544
-
3545
- \`\`\`json
3546
- { "scores": { "Explorer": 3, "Homebody": 0, "Foodie": 1 } }
3547
- \`\`\`
3548
-
3549
- Guidelines:
3550
- - **Primary category**: 2-3 points (this is the option's "home" category)
3551
- - **Secondary**: 1 point (slight lean towards another category)
3552
- - **Unrelated**: 0 (omit or set to 0)
3553
- - **Knockout**: -1 (use sparingly - strongly rules out a category)
3554
- - Every option should score positively in at least one category
3555
- - Avoid giving every option the same spread - it makes results feel random
3556
-
3557
- ## Question Design
3558
-
3559
- - Questions should feel **personally revealing** but low-stakes: "Pick your ideal Saturday morning" not "What's your biggest weakness?"
3560
- - Scenario-based questions work better than abstract preference questions
3561
- - Each question should meaningfully differentiate between categories
3562
- - Avoid questions where all options clearly map to one obvious personality
3563
-
3564
- ## Outcome Screens
3565
-
3566
- Each category needs a \`scoring_results\` entry on the end screen:
3567
-
3568
- - **Title**: "You're a [Category]!" - celebratory, not clinical
3569
- - **Message**: 2-3 sentences that feel like a personalised insight. Reference specific traits the quiz measured.
3570
- - **Optional CTA**: link to relevant content, product, or next step
3571
-
3572
- ## Narration Style
3573
-
3574
- Reflective and curious, not quizmaster-y:
3575
- - "This one says a lot about you..."
3576
- - "There's no wrong answer here - go with your gut"
3577
- - "What does your choice reveal?"
3578
-
3579
- Do NOT say "let's see if you get this right" - there is no right answer.
3580
-
3581
- ${WRITING_PRINCIPLES}
3582
-
3583
- ${MEDIA_WORKFLOW}`
3584
- }
3585
- ]
3586
- })
3587
- );
3588
- server.registerResource(
3589
- "guide-interview",
3590
- "clipform://guides/interview",
3591
- {
3592
- description: "Craft knowledge for building interview and testimonial collection forms - warm-up pacing, open questions, consent, video responses",
3593
- mimeType: "text/markdown"
3594
- },
3595
- async () => ({
3596
- contents: [
3597
- {
3598
- uri: "clipform://guides/interview",
3599
- mimeType: "text/markdown",
3600
- text: `# Interview & Testimonial Guide
3601
-
3602
- ## Purpose
3603
-
3604
- Collect responses from people - testimonials, case studies, journalist callouts, async video interviews, candidate screening. The common thread: you're asking someone to respond on camera or in their own words.
3605
-
3606
- ## Structure
3607
-
3608
- 1. **Warm-up question** - something easy and low-stakes to get them comfortable. "Tell us your name and what you do" or "What's your role?"
3609
- 2. **Core questions** - the real ask. Open-ended, one topic per question. Don't over-split - 2-3 core questions max.
3610
- 3. **Follow-up** (optional) - "Anything else you'd like to add?" catches things you didn't think to ask.
3611
- 4. **Contact collection** - first name + email minimum. Add phone/company if relevant.
3612
- 5. **Consent** - "I agree that my response may be used in [context]." Always include for testimonials and media.
3613
- 6. **End screen** - set expectations: "Thanks! We'll be in touch if we'd like to take things further."
3614
-
3615
- ## Question Design
3616
-
3617
- - **Open-ended by default.** Use "open" type with text + audio + video response enabled. Let the respondent choose their format.
3618
- - **One topic per question.** "Tell us about your experience AND what you'd change" is two questions.
3619
- - **Prompt, don't interrogate.** "What surprised you most about working with us?" beats "Rate your satisfaction."
3620
- - **Keep it short.** 3-5 questions total. Every extra question loses respondents.
3621
-
3622
- ## Narration for Interviews
3623
-
3624
- Warmer and more personal than quiz narration. You're inviting someone to share, not testing them.
3625
-
3626
- - "We'd love to hear your story..."
3627
- - "Take your time with this one - there's no wrong answer"
3628
- - Keep narration under 10 seconds - the respondent's answer is the content, not yours
3629
-
3630
- ## Settings
3631
-
3632
- - disable_back_navigation: false (let people review their answers)
3633
- - show_step_counter: true (so they know how much is left)
3634
-
3635
- ${WRITING_PRINCIPLES}
3636
-
3637
- ${MEDIA_WORKFLOW}`
3638
- }
3639
- ]
3640
- })
3641
- );
3642
- server.registerResource(
3643
- "guide-survey",
3644
- "clipform://guides/survey",
3645
- {
3646
- description: "Craft knowledge for feedback surveys, NPS, and research forms - brevity, rating scales, respondent fatigue",
3647
- mimeType: "text/markdown"
3648
- },
3649
- async () => ({
3650
- contents: [
3651
- {
3652
- uri: "clipform://guides/survey",
3653
- mimeType: "text/markdown",
3654
- text: `# Survey & Feedback Guide
3655
-
3656
- ## Purpose
3657
-
3658
- Collect structured feedback - NPS, customer satisfaction, product research, post-event feedback. The goal is clean, analysable data with minimal respondent fatigue.
3659
-
3660
- ## Structure
3661
-
3662
- Surveys should be ruthlessly short. Every extra question costs you completions.
3663
-
3664
- 1. **Key metric** - the one number you care about (NPS, satisfaction rating, likelihood to recommend). Put it first while attention is highest.
3665
- 2. **Follow-up** - one open-ended "why?" question. "What's the main reason for your score?" This is where the insight lives.
3666
- 3. **Specific questions** (optional) - 2-3 targeted choice questions on specific areas. Don't fish - only ask what you'll act on.
3667
- 4. **Contact** (optional) - only if you need to follow up. Many surveys are better anonymous.
3668
- 5. **End screen** - "Thanks for your feedback!" Keep it simple.
3669
-
3670
- ## Question Design
3671
-
3672
- - **Choice questions for data, open questions for insight.** Don't use open-ended where a rating scale would do, and don't use ratings where you need to understand why.
3673
- - **Balanced scales.** Equal positive and negative options. "Excellent / Good / Fair / Poor" not "Amazing / Great / Good / OK / Bad."
3674
- - **No leading questions.** "How much did you enjoy...?" assumes they enjoyed it.
3675
- - **5 questions max.** If you need more, you're running research, not a survey - split it up.
3676
-
3677
- ## Narration for Surveys
3678
-
3679
- Optional - many surveys work fine as text only. If using narration:
3680
-
3681
- - Keep it brief (5-8 seconds). "We'd love your quick feedback on..."
3682
- - Don't narrate every question - just the opener to set the tone
3683
- - Friendly but efficient. Respect their time.
3684
-
3685
- ## Settings
3686
-
3687
- - disable_back_navigation: false
3688
- - show_step_counter: true (shows progress, reduces abandonment)
3689
-
3690
- ${WRITING_PRINCIPLES}`
3691
- }
3692
- ]
3693
- })
3694
- );
3695
- server.registerResource(
3696
- "guide-funnel",
3697
- "clipform://guides/funnel",
3698
- {
3699
- description: "Craft knowledge for lead qualification funnels and product recommendation quizzes - branching logic, progressive profiling, conversion",
3700
- mimeType: "text/markdown"
3701
- },
3702
- async () => ({
3703
- contents: [
3704
- {
3705
- uri: "clipform://guides/funnel",
3706
- mimeType: "text/markdown",
3707
- text: `# Lead Qualification & Product Recommendation Guide
3708
-
3709
- ## Purpose
3710
-
3711
- Route people to the right outcome based on their answers. Lead qualification scores and segments prospects. Product recommendation guides users to the right product. Both use branching logic to personalise the journey.
3712
-
3713
- ## Structure
3714
-
3715
- 1. **Hook question** - immediately relevant, low friction. "What are you looking for?" or "What best describes you?" This segments the user and determines the branch.
3716
- 2. **Qualifying questions** - 2-3 questions that narrow down the need. Each answer can branch to a different path.
3717
- 3. **Contact capture** - name, email, phone. Place AFTER qualifying questions so they've invested before you ask for details.
3718
- 4. **Outcome screen** - personalised end screen based on their answers. Use score_ranges or branching logic to show different messages.
3719
-
3720
- ## Question Design
3721
-
3722
- - **Progressive profiling.** Start broad, get specific. Don't ask for budget on question 1.
3723
- - **Every question must earn its place.** If the answer doesn't change the outcome or routing, cut the question.
3724
- - **Choice questions, not open-ended.** You need structured data to route and score. Save open-ended for "anything else?"
3725
- - **3-5 questions max.** Every extra step loses leads. The funnel is leaky by nature - keep it tight.
3726
-
3727
- ## Branching Logic
3728
-
3729
- Use clipform_set_logic to route based on answers:
3730
-
3731
- - **Segment early.** Q1 answer determines which Q2 they see.
3732
- - **Converge at capture.** All branches should reach the contact collection step.
3733
- - **Different outcomes for different segments.** The end screen should feel personalised: "Based on your answers, we recommend..." not a generic "Thanks!"
3734
-
3735
- ## Scoring for Product Recommendation
3736
-
3737
- - Assign scores to each option based on which product/outcome it points to
3738
- - Use score_ranges on the end screen to show different recommendations
3739
- - Example: score 0-3 = "Basic plan", 4-6 = "Pro plan", 7+ = "Enterprise - let's talk"
3740
-
3741
- ## Narration for Funnels
3742
-
3743
- Short and action-oriented. You're guiding, not teaching.
3744
-
3745
- - "Let's find the right fit for you..."
3746
- - "Just a couple of quick questions..."
3747
- - Keep total narration under 30 seconds across the whole funnel
3748
-
3749
- ## Settings
3750
-
3751
- - disable_back_navigation: true (prevent answer shopping that breaks scoring)
3752
- - show_step_counter: false (funnels feel shorter without a counter)
3753
-
3754
- ${WRITING_PRINCIPLES}`
3755
- }
3756
- ]
3757
- })
3758
- );
3759
- server.registerResource(
3760
- "context-session",
3761
- "clipform://context/session",
3762
- {
3763
- description: "Current session info: auth mode, workspace, plan tier, node limits, feature flags. Read this before planning content to know your constraints.",
3764
- mimeType: "text/markdown",
3765
- annotations: { audience: ["assistant"], priority: 1 }
3766
- },
3767
- async () => {
3768
- const text = await getSessionContext();
3769
- return {
3770
- contents: [
3771
- {
3772
- uri: "clipform://context/session",
3773
- mimeType: "text/markdown",
3774
- text: text || "Session context unavailable - API may not be reachable."
3775
- }
3776
- ]
3777
- };
3778
- }
3779
- );
3780
- }
3781
-
3782
- // src/server.ts
3783
- var __dirname = dirname(fileURLToPath(import.meta.url));
3784
- var pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
3785
- var MCP_VERSION = pkg.version;
3786
- function createServer() {
3787
- const server = new McpServer({
3788
- name: "clipform-mcp-server",
3789
- version: MCP_VERSION
3790
- });
3791
- registerCreateFormTool(server);
3792
- registerListFormsTool(server);
3793
- registerGetFormTool(server);
3794
- registerUpdateFormTool(server);
3795
- registerDeleteFormTool(server);
3796
- registerAddNodeTool(server);
3797
- registerUpdateNodeTool(server);
3798
- registerDeleteNodeTool(server);
3799
- registerUploadNodeMediaTool(server);
3800
- registerGetNodeMediaTool(server);
3801
- registerDeleteNodeMediaTool(server);
3802
- registerSetNodeLogicTool(server);
3803
- registerAttachNodeAudioTool(server);
3804
- registerLogGenerationTool(server);
3805
- registerSearchNewsTool(server);
3806
- registerGenerateTtsTool(server);
3807
- registerGenerateSlideshowTool(server);
3808
- registerGenerateVideoTool(server);
3809
- registerSearchMediaTool(server);
3810
- registerRenderCompositionTool(server);
3811
- registerSearchMusicTool(server);
3812
- registerListCompositionsTool(server);
3813
- registerListAssetsTool(server);
3814
- registerCheckRenderTool(server);
3815
- registerPrompts(server);
3816
- registerResources(server);
3817
- return server;
3818
- }
3819
-
3820
- export {
3821
- setApiKey,
3822
- createServer
3823
- };
3824
- //# sourceMappingURL=chunk-BWCZX7XY.js.map