@ssemble/mcp-server 1.1.4 → 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 +1 -1
- package/server.json +2 -2
- package/src/api/client.js +72 -3
- package/src/tools/create-short.js +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssemble/mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
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.
|
|
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.
|
|
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':
|
|
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
|
|
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
|
-
|
|
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);
|
|
@@ -37,6 +37,13 @@ const schema = {
|
|
|
37
37
|
musicName: z.string().describe('Exact track name from list_music (case-sensitive)').optional(),
|
|
38
38
|
musicVolume: z.number().min(0).max(100).default(10).describe('Music volume 0-100').optional(),
|
|
39
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(),
|
|
40
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(),
|
|
41
48
|
webhookUrl: z.string().url().describe('Optional webhook URL for completion/failure notifications. Receives a POST with results when processing finishes.').optional(),
|
|
42
49
|
};
|
|
@@ -62,6 +69,10 @@ export function registerCreateShort(server, client) {
|
|
|
62
69
|
if (params.ctaEnabled && !params.ctaText) {
|
|
63
70
|
return { content: [{ type: 'text', text: formatError({ message: 'ctaText is required when ctaEnabled is true', code: 'invalid_request' }) }], isError: true };
|
|
64
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
|
+
}
|
|
65
76
|
|
|
66
77
|
try {
|
|
67
78
|
const result = await client.createShort(params);
|