@ssemble/mcp-server 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssemble/mcp-server",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "mcpName": "com.ssemble/mcp-server",
5
5
  "description": "MCP server for Ssemble AI Clipping — create viral short-form videos from any AI assistant. Webhook callbacks for n8n, Zapier, and Make automation.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.ssemble/mcp-server",
4
4
  "title": "Ssemble AI Clipping",
5
5
  "description": "Create AI-powered short-form video clips from YouTube videos. Supports webhook callbacks.",
6
- "version": "1.1.3",
6
+ "version": "1.1.5",
7
7
  "websiteUrl": "https://www.ssemble.com/mcp",
8
8
  "repository": {
9
9
  "url": "https://github.com/ssembleinc/ssemble-mcp-server",
@@ -14,7 +14,7 @@
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "@ssemble/mcp-server",
17
- "version": "1.1.3",
17
+ "version": "1.1.5",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
package/src/api/client.js CHANGED
@@ -3,7 +3,36 @@
3
3
  * Wraps all 9 Ssemble AI Clipping API endpoints with native fetch.
4
4
  */
5
5
 
6
+ import { createRequire } from 'module';
7
+
6
8
  const DEFAULT_BASE_URL = 'https://aiclipping.ssemble.com/api/v1';
9
+ const PKG_VERSION = createRequire(import.meta.url)('../../package.json').version;
10
+
11
+ // Per-attempt timeout. Backend create/validate calls can take a while (YouTube
12
+ // metadata lookup), so give them more headroom than plain reads.
13
+ const DEFAULT_TIMEOUT_MS = 30_000;
14
+ const CREATE_TIMEOUT_MS = 90_000;
15
+ const MAX_RETRIES = 2;
16
+
17
+ // Only statuses where the request provably did NOT run application logic are
18
+ // retried for POST (429 = rejected by limiter, 502/503 = never reached the app).
19
+ // A 504 or a dropped socket may have already created a request and charged a
20
+ // credit, so POST is never retried on those. GET/DELETE are idempotent.
21
+ const RETRY_STATUSES_ANY = new Set([429, 502, 503]);
22
+ const RETRY_STATUSES_IDEMPOTENT = new Set([429, 500, 502, 503, 504]);
23
+
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+
26
+ function retryDelayMs(response, attempt) {
27
+ const ra = response?.headers?.get('retry-after');
28
+ if (ra) {
29
+ const secs = Number(ra);
30
+ if (Number.isFinite(secs)) return Math.min(secs, 30) * 1000;
31
+ const at = Date.parse(ra);
32
+ if (!Number.isNaN(at)) return Math.max(0, Math.min(at - Date.now(), 30_000));
33
+ }
34
+ return 1000 * 2 ** attempt; // 1s, 2s
35
+ }
7
36
 
8
37
  export class SsembleApiError extends Error {
9
38
  constructor(status, error, rateLimits) {
@@ -60,7 +89,7 @@ export class SsembleClient {
60
89
  const headers = {
61
90
  'X-API-Key': this.apiKey,
62
91
  'Content-Type': 'application/json',
63
- 'User-Agent': 'ssemble-mcp-server/1.0.5',
92
+ 'User-Agent': `ssemble-mcp-server/${PKG_VERSION}`,
64
93
  };
65
94
 
66
95
  const options = { method, headers };
@@ -68,7 +97,36 @@ export class SsembleClient {
68
97
  options.body = JSON.stringify(body);
69
98
  }
70
99
 
71
- const response = await fetch(url, options);
100
+ const idempotent = method === 'GET' || method === 'DELETE';
101
+ const retryStatuses = idempotent ? RETRY_STATUSES_IDEMPOTENT : RETRY_STATUSES_ANY;
102
+ const timeoutMs = path === '/shorts/create' ? CREATE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
103
+
104
+ let response;
105
+ for (let attempt = 0; ; attempt++) {
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
108
+ try {
109
+ response = await fetch(url, { ...options, signal: controller.signal });
110
+ } catch (err) {
111
+ clearTimeout(timer);
112
+ const timedOut = err?.name === 'AbortError';
113
+ // Network failures / timeouts: safe to retry only when nothing could
114
+ // have been created server-side (idempotent calls).
115
+ if (idempotent && attempt < MAX_RETRIES) { await sleep(retryDelayMs(null, attempt)); continue; }
116
+ throw new SsembleApiError(timedOut ? 504 : 503, {
117
+ code: timedOut ? 'timeout' : 'network_error',
118
+ message: timedOut
119
+ ? `Ssemble API did not respond within ${timeoutMs / 1000}s (${method} ${path}). ${idempotent ? 'Please retry.' : 'If this was create_short, check list_requests before retrying so you are not charged twice.'}`
120
+ : `Could not reach the Ssemble API (${err?.message || 'network error'}).`,
121
+ });
122
+ }
123
+ clearTimeout(timer);
124
+ if (retryStatuses.has(response.status) && attempt < MAX_RETRIES) {
125
+ await sleep(retryDelayMs(response, attempt));
126
+ continue;
127
+ }
128
+ break;
129
+ }
72
130
 
73
131
  const rateLimits = {
74
132
  hourlyLimit: response.headers.get('x-ratelimit-limit-hour'),
@@ -82,7 +140,18 @@ export class SsembleClient {
82
140
  return { data: { message: 'Request successfully deleted' }, rateLimits };
83
141
  }
84
142
 
85
- const data = await response.json();
143
+ // Gateways (Cloudflare / the load balancer) answer 5xx with HTML, not JSON.
144
+ let data;
145
+ try {
146
+ data = await response.json();
147
+ } catch {
148
+ throw new SsembleApiError(response.status, {
149
+ code: response.ok ? 'invalid_response' : 'upstream_error',
150
+ message: response.ok
151
+ ? 'Ssemble API returned a non-JSON response'
152
+ : `Ssemble API is temporarily unavailable (HTTP ${response.status}). Please retry in a moment.`,
153
+ }, rateLimits);
154
+ }
86
155
 
87
156
  if (!response.ok) {
88
157
  throw new SsembleApiError(response.status, data.error, rateLimits);
@@ -7,6 +7,13 @@ const SUPPORTED_LANGUAGES = {
7
7
  pt: 'Portuguese', ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese',
8
8
  ar: 'Arabic', hi: 'Hindi', tr: 'Turkish', pl: 'Polish', nl: 'Dutch',
9
9
  sv: 'Swedish', no: 'Norwegian', da: 'Danish', fi: 'Finnish', cs: 'Czech',
10
+ vi: 'Vietnamese', id: 'Indonesian', fa: 'Persian (Farsi)', uk: 'Ukrainian',
11
+ af: 'Afrikaans', ca: 'Catalan', et: 'Estonian', fo: 'Faroese', gl: 'Galician',
12
+ is: 'Icelandic', ms: 'Malay', sq: 'Albanian', sw: 'Swahili', tl: 'Filipino (Tagalog)',
13
+ ro: 'Romanian', sk: 'Slovak', hr: 'Croatian', sl: 'Slovenian', hu: 'Hungarian',
14
+ lv: 'Latvian', lt: 'Lithuanian', bs: 'Bosnian', az: 'Azerbaijani',
15
+ bg: 'Bulgarian', mk: 'Macedonian', sr: 'Serbian', mt: 'Maltese', el: 'Greek',
16
+ ne: 'Nepali', mr: 'Marathi', ka: 'Georgian', hy: 'Armenian', ur: 'Urdu', he: 'Hebrew',
10
17
  };
11
18
 
12
19
  const PREFERRED_LENGTHS = {
@@ -21,7 +28,7 @@ const PREFERRED_LENGTHS = {
21
28
  const LAYOUTS = {
22
29
  auto: 'Automatic — AI detects best framing based on speaker position',
23
30
  fill: 'Fill — crop to fill 9:16 vertical frame',
24
- fit: 'Fit — fit entire frame with bars if needed',
31
+ fit: 'Fit — fit entire frame; the area around the video is a blurred copy of the video (default) or the template color when blurBackground=false',
25
32
  square: 'Square — 1:1 aspect ratio',
26
33
  };
27
34
 
@@ -52,6 +59,7 @@ export function registerResources(server) {
52
59
  text += `| \`${value}\` | ${desc} |\n`;
53
60
  }
54
61
  text += '\nDefault: `under60sec`';
62
+ text += '\n\nTo limit how many shorts a request creates, pass `maxShorts` (1-12) to `create_short`. Omit it to get up to 12.';
55
63
  return { contents: [{ uri: 'ssemble://preferred-lengths', mimeType: 'text/markdown', text }] };
56
64
  }
57
65
  );
@@ -2,9 +2,15 @@ import { z } from 'zod';
2
2
  import { formatCreateShortResponse, formatError } from '../utils/format.js';
3
3
  import { saveRequest } from '../utils/session-store.js';
4
4
 
5
+ // Mirrors the backend list (plugin-shortsmaker-node-backend/constants/apiConstants.js
6
+ // SUPPORTED_LANGUAGES) and the ssemble://supported-languages resource. Keep all three in sync.
5
7
  const SUPPORTED_LANGUAGES = [
6
8
  'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'zh',
7
9
  'ar', 'hi', 'tr', 'pl', 'nl', 'sv', 'no', 'da', 'fi', 'cs',
10
+ 'vi', 'id', 'fa', 'uk',
11
+ 'af', 'ca', 'et', 'fo', 'gl', 'is', 'ms', 'sq', 'sw', 'tl',
12
+ 'ro', 'sk', 'hr', 'sl', 'hu', 'lv', 'lt', 'bs', 'az',
13
+ 'bg', 'mk', 'sr', 'mt', 'el', 'ne', 'mr', 'ka', 'hy', 'ur', 'he',
8
14
  ];
9
15
 
10
16
  const schema = {
@@ -14,6 +20,8 @@ const schema = {
14
20
  end: z.number().min(1).describe('End time in seconds (> start, max 1200s window)'),
15
21
  preferredLength: z.enum(['under30sec', 'under60sec', 'under90sec', 'under3min', 'under5min', 'under10min'])
16
22
  .default('under60sec').describe('Target clip duration').optional(),
23
+ maxShorts: z.number().int().min(1).max(12)
24
+ .describe('Maximum number of shorts to create (1-12). Keeps the highest-scoring clips and never pads, so short videos may return fewer. Omit to get up to 12.').optional(),
17
25
  language: z.enum(SUPPORTED_LANGUAGES).default('en').describe('Spoken language (ISO 639-1)').optional(),
18
26
  captionLanguage: z.enum(SUPPORTED_LANGUAGES).describe('Caption language if different from spoken').optional(),
19
27
  templateId: z.string().regex(/^[0-9a-fA-F]{24}$/).describe('Caption template ID from list_templates (24-char hex)').optional(),
@@ -29,6 +37,14 @@ const schema = {
29
37
  musicName: z.string().describe('Exact track name from list_music (case-sensitive)').optional(),
30
38
  musicVolume: z.number().min(0).max(100).default(10).describe('Music volume 0-100').optional(),
31
39
  layout: z.enum(['auto', 'fill', 'fit', 'square']).default('auto').describe('Video framing layout').optional(),
40
+ captionsEnabled: z.boolean().default(true).describe('Burn captions into the clips (default true). Set false for sources that already have captions.').optional(),
41
+ outroEnabled: z.boolean().default(false).describe('Append an end-screen outro card (bell animation + "Please subscribe" text) after each clip. Default false.').optional(),
42
+ outroText: z.string().max(80).describe('Custom outro text (max 80 chars). Omit for the localized default ("Please subscribe"). Requires outroEnabled=true.').optional(),
43
+ outroImageUrl: z.string().url().describe('Public image URL for a custom outro card instead of the stock bell card. Requires outroEnabled=true.').optional(),
44
+ outroSeconds: z.number().int().min(1).max(3).default(2).describe('Outro duration in seconds (1-3, default 2). Requires outroEnabled=true.').optional(),
45
+ outroSting: z.boolean().default(true).describe('Play the short bell sound on the outro (default true). Requires outroEnabled=true.').optional(),
46
+ outroHandle: z.string().max(40).describe('Channel handle shown on the outro card, e.g. "@mychannel" (max 40 chars). Requires outroEnabled=true.').optional(),
47
+ blurBackground: z.boolean().describe('Background behind the clip when the source does not fill the 9:16 canvas (mainly layout=fit): true = blurred copy of the video, false = solid template color. Omit to use the account\'s saved editor preference (default: blurred).').optional(),
32
48
  webhookUrl: z.string().url().describe('Optional webhook URL for completion/failure notifications. Receives a POST with results when processing finishes.').optional(),
33
49
  };
34
50
 
@@ -53,6 +69,10 @@ export function registerCreateShort(server, client) {
53
69
  if (params.ctaEnabled && !params.ctaText) {
54
70
  return { content: [{ type: 'text', text: formatError({ message: 'ctaText is required when ctaEnabled is true', code: 'invalid_request' }) }], isError: true };
55
71
  }
72
+ const outroExtras = ['outroText', 'outroImageUrl', 'outroHandle'].filter((k) => params[k] !== undefined && params[k] !== '');
73
+ if (outroExtras.length && params.outroEnabled !== true) {
74
+ return { content: [{ type: 'text', text: formatError({ message: `${outroExtras.join(', ')} require outroEnabled: true`, code: 'invalid_request' }) }], isError: true };
75
+ }
56
76
 
57
77
  try {
58
78
  const result = await client.createShort(params);