@ssemble/mcp-server 1.0.0
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/LICENSE +21 -0
- package/README.md +132 -0
- package/package.json +47 -0
- package/server.json +34 -0
- package/src/api/client.js +122 -0
- package/src/index.js +50 -0
- package/src/prompts/workflows.js +63 -0
- package/src/resources/static-data.js +73 -0
- package/src/tools/create-short.js +63 -0
- package/src/tools/delete-request.js +22 -0
- package/src/tools/get-shorts.js +22 -0
- package/src/tools/get-status.js +22 -0
- package/src/tools/list-game-videos.js +23 -0
- package/src/tools/list-meme-hooks.js +23 -0
- package/src/tools/list-music.js +23 -0
- package/src/tools/list-requests.js +26 -0
- package/src/tools/list-templates.js +17 -0
- package/src/transports/http.js +83 -0
- package/src/transports/stdio.js +22 -0
- package/src/utils/format.js +191 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ssemble
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Ssemble MCP Server
|
|
2
|
+
|
|
3
|
+
MCP server for [Ssemble AI Clipping](https://aiclipping.ssemble.com) — create viral short-form videos from any AI assistant.
|
|
4
|
+
|
|
5
|
+
Works with **Claude Desktop**, **Claude Code**, **Cursor**, **VS Code**, **Windsurf**, and any MCP-compatible client.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
This MCP server lets AI assistants create short-form video clips from YouTube videos using Ssemble's AI clipping engine. The AI can:
|
|
10
|
+
|
|
11
|
+
- **Create shorts** from YouTube URLs or uploaded files
|
|
12
|
+
- **Browse assets** — caption templates, background music, gameplay overlays, meme hooks
|
|
13
|
+
- **Track processing** — check progress and retrieve completed clips
|
|
14
|
+
- **Manage requests** — list history, get results, delete old requests
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- [Ssemble account](https://app.ssemble.com) with an active subscription
|
|
19
|
+
- API key (get it from Settings → API Keys in the Ssemble dashboard)
|
|
20
|
+
- Node.js >= 18
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### Claude Desktop
|
|
25
|
+
|
|
26
|
+
Edit your config file:
|
|
27
|
+
|
|
28
|
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
29
|
+
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
30
|
+
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"ssemble": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["@ssemble/mcp-server"],
|
|
38
|
+
"env": {
|
|
39
|
+
"SSEMBLE_API_KEY": "sk_ssemble_your_key_here"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart Claude Desktop after saving.
|
|
47
|
+
|
|
48
|
+
### Claude Code
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add ssemble -- npx @ssemble/mcp-server
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then set the environment variable:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export SSEMBLE_API_KEY="sk_ssemble_your_key_here"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Cursor / VS Code / Windsurf
|
|
61
|
+
|
|
62
|
+
Add to your MCP settings:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"ssemble": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["@ssemble/mcp-server"],
|
|
70
|
+
"env": {
|
|
71
|
+
"SSEMBLE_API_KEY": "sk_ssemble_your_key_here"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Available Tools
|
|
79
|
+
|
|
80
|
+
| Tool | Description |
|
|
81
|
+
|------|-------------|
|
|
82
|
+
| `create_short` | Create AI-generated short clips from a video. Returns request ID instantly. |
|
|
83
|
+
| `get_status` | Check processing progress (0-100%) and current step |
|
|
84
|
+
| `get_shorts` | Retrieve completed clips with video URLs, titles, viral scores |
|
|
85
|
+
| `list_requests` | List all your requests with pagination and status filtering |
|
|
86
|
+
| `delete_request` | Permanently delete a request and its videos |
|
|
87
|
+
| `list_templates` | Browse caption style templates |
|
|
88
|
+
| `list_music` | Browse background music tracks |
|
|
89
|
+
| `list_game_videos` | Browse gameplay overlay videos |
|
|
90
|
+
| `list_meme_hooks` | Browse meme hook attention-grabber clips |
|
|
91
|
+
|
|
92
|
+
## How Processing Works
|
|
93
|
+
|
|
94
|
+
Video processing takes 5-30 minutes. The workflow is:
|
|
95
|
+
|
|
96
|
+
1. `create_short` — submits the job and returns instantly with a request ID
|
|
97
|
+
2. `get_status` — check progress anytime (queued → processing → completed)
|
|
98
|
+
3. `get_shorts` — retrieve the generated clips when done
|
|
99
|
+
|
|
100
|
+
## Usage Examples
|
|
101
|
+
|
|
102
|
+
**Create shorts from a YouTube video:**
|
|
103
|
+
> "Create shorts from https://youtube.com/watch?v=abc123, use the first 10 minutes with chill background music"
|
|
104
|
+
|
|
105
|
+
**Browse available assets:**
|
|
106
|
+
> "Show me all caption templates" / "What background music is available?"
|
|
107
|
+
|
|
108
|
+
**Check progress:**
|
|
109
|
+
> "What's the status of my video request?"
|
|
110
|
+
|
|
111
|
+
**Get results:**
|
|
112
|
+
> "Show me the clips for request 507f1f77bcf86cd799439011, sorted by viral score"
|
|
113
|
+
|
|
114
|
+
## Environment Variables
|
|
115
|
+
|
|
116
|
+
| Variable | Required | Description |
|
|
117
|
+
|----------|----------|-------------|
|
|
118
|
+
| `SSEMBLE_API_KEY` | Yes | Your Ssemble API key (starts with `sk_ssemble_`) |
|
|
119
|
+
| `SSEMBLE_API_BASE_URL` | No | Override API base URL (default: `https://aiclipping.ssemble.com/api/v1`) |
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
git clone https://gitlab.com/vlogr/ssemble-mcp-server.git
|
|
125
|
+
cd ssemble-mcp-server
|
|
126
|
+
npm install
|
|
127
|
+
SSEMBLE_API_KEY=sk_ssemble_your_key npm start
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ssemble/mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Ssemble AI Clipping — create viral short-form videos from any AI assistant",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ssemble-mcp": "./src/transports/stdio.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"server.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node src/transports/stdio.js",
|
|
18
|
+
"start:http": "node src/transports/http.js",
|
|
19
|
+
"test": "node --test tests/**/*.test.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"ssemble",
|
|
25
|
+
"ai-clipping",
|
|
26
|
+
"video",
|
|
27
|
+
"shorts",
|
|
28
|
+
"youtube",
|
|
29
|
+
"tiktok",
|
|
30
|
+
"instagram-reels"
|
|
31
|
+
],
|
|
32
|
+
"author": "Ssemble <support@ssemble.com>",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://gitlab.com/vlogr/ssemble-mcp-server.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://aiclipping.ssemble.com",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
44
|
+
"express": "^4.21.0",
|
|
45
|
+
"zod": "^3.23.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.gitlab.vlogr/ssemble-mcp-server",
|
|
4
|
+
"title": "Ssemble AI Clipping",
|
|
5
|
+
"description": "Create AI-powered short-form video clips from YouTube videos or uploaded files. Browse caption templates, background music, gameplay overlays, and meme hooks.",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"websiteUrl": "https://aiclipping.ssemble.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://gitlab.com/vlogr/ssemble-mcp-server.git"
|
|
11
|
+
},
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "npm",
|
|
15
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
|
+
"identifier": "@ssemble/mcp-server",
|
|
17
|
+
"version": "1.0.0",
|
|
18
|
+
"transport": "stdio",
|
|
19
|
+
"environmentVariables": [
|
|
20
|
+
{
|
|
21
|
+
"name": "SSEMBLE_API_KEY",
|
|
22
|
+
"description": "Your Ssemble API key (starts with sk_ssemble_). Get it at https://app.ssemble.com → Settings → API Keys",
|
|
23
|
+
"required": true
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"remotes": [
|
|
29
|
+
{
|
|
30
|
+
"transportType": "streamable-http",
|
|
31
|
+
"url": "https://mcp.ssemble.com/mcp"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ssemble API Client
|
|
3
|
+
* Wraps all 9 Ssemble AI Clipping API endpoints with native fetch.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BASE_URL = 'https://aiclipping.ssemble.com/api/v1';
|
|
7
|
+
|
|
8
|
+
export class SsembleApiError extends Error {
|
|
9
|
+
constructor(status, error, rateLimits) {
|
|
10
|
+
super(error?.message || `API error (${status})`);
|
|
11
|
+
this.name = 'SsembleApiError';
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = error?.code || 'unknown_error';
|
|
14
|
+
this.details = error?.details || null;
|
|
15
|
+
this.rateLimits = rateLimits;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SsembleClient {
|
|
20
|
+
constructor(apiKey, baseUrl) {
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
throw new Error('SSEMBLE_API_KEY is required');
|
|
23
|
+
}
|
|
24
|
+
if (!apiKey.startsWith('sk_ssemble_')) {
|
|
25
|
+
throw new Error('Invalid API key format. Must start with sk_ssemble_');
|
|
26
|
+
}
|
|
27
|
+
this.apiKey = apiKey;
|
|
28
|
+
this.baseUrl = baseUrl || DEFAULT_BASE_URL;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async _request(method, path, { body, query } = {}) {
|
|
32
|
+
let url = `${this.baseUrl}${path}`;
|
|
33
|
+
|
|
34
|
+
if (query) {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
for (const [key, value] of Object.entries(query)) {
|
|
37
|
+
if (value !== undefined && value !== null) {
|
|
38
|
+
params.set(key, String(value));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const qs = params.toString();
|
|
42
|
+
if (qs) url += `?${qs}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const headers = {
|
|
46
|
+
'X-API-Key': this.apiKey,
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const options = { method, headers };
|
|
51
|
+
if (body) {
|
|
52
|
+
options.body = JSON.stringify(body);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const response = await fetch(url, options);
|
|
56
|
+
|
|
57
|
+
const rateLimits = {
|
|
58
|
+
hourlyLimit: response.headers.get('x-ratelimit-limit-hour'),
|
|
59
|
+
hourlyRemaining: response.headers.get('x-ratelimit-remaining-hour'),
|
|
60
|
+
dailyLimit: response.headers.get('x-ratelimit-limit-day'),
|
|
61
|
+
dailyRemaining: response.headers.get('x-ratelimit-remaining-day'),
|
|
62
|
+
reset: response.headers.get('x-ratelimit-reset'),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (method === 'DELETE' && response.status === 204) {
|
|
66
|
+
return { data: { message: 'Request successfully deleted' }, rateLimits };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new SsembleApiError(response.status, data.error, rateLimits);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ...data, rateLimits };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Endpoint 1: GET /templates
|
|
79
|
+
async listTemplates() {
|
|
80
|
+
return this._request('GET', '/templates');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Endpoint 2: POST /shorts/create
|
|
84
|
+
async createShort(params) {
|
|
85
|
+
return this._request('POST', '/shorts/create', { body: params });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Endpoint 3: GET /shorts/:id/status
|
|
89
|
+
async getStatus(requestId) {
|
|
90
|
+
return this._request('GET', `/shorts/${requestId}/status`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Endpoint 4: GET /shorts/:id
|
|
94
|
+
async getShorts(requestId) {
|
|
95
|
+
return this._request('GET', `/shorts/${requestId}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Endpoint 5: GET /shorts
|
|
99
|
+
async listRequests(query) {
|
|
100
|
+
return this._request('GET', '/shorts', { query });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Endpoint 6: DELETE /shorts/:id
|
|
104
|
+
async deleteRequest(requestId) {
|
|
105
|
+
return this._request('DELETE', `/shorts/${requestId}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Endpoint 7: GET /music
|
|
109
|
+
async listMusic(query) {
|
|
110
|
+
return this._request('GET', '/music', { query });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Endpoint 8: GET /game-videos
|
|
114
|
+
async listGameVideos(query) {
|
|
115
|
+
return this._request('GET', '/game-videos', { query });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Endpoint 9: GET /meme-hooks
|
|
119
|
+
async listMemeHooks(query) {
|
|
120
|
+
return this._request('GET', '/meme-hooks', { query });
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ssemble MCP Server — factory function.
|
|
3
|
+
* Creates and configures the McpServer with all tools, resources, and prompts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { SsembleClient } from './api/client.js';
|
|
8
|
+
|
|
9
|
+
// Tools
|
|
10
|
+
import { registerCreateShort } from './tools/create-short.js';
|
|
11
|
+
import { registerGetStatus } from './tools/get-status.js';
|
|
12
|
+
import { registerGetShorts } from './tools/get-shorts.js';
|
|
13
|
+
import { registerListRequests } from './tools/list-requests.js';
|
|
14
|
+
import { registerDeleteRequest } from './tools/delete-request.js';
|
|
15
|
+
import { registerListTemplates } from './tools/list-templates.js';
|
|
16
|
+
import { registerListMusic } from './tools/list-music.js';
|
|
17
|
+
import { registerListGameVideos } from './tools/list-game-videos.js';
|
|
18
|
+
import { registerListMemeHooks } from './tools/list-meme-hooks.js';
|
|
19
|
+
|
|
20
|
+
// Resources & Prompts
|
|
21
|
+
import { registerResources } from './resources/static-data.js';
|
|
22
|
+
import { registerPrompts } from './prompts/workflows.js';
|
|
23
|
+
|
|
24
|
+
export function createSsembleMcpServer(apiKey, baseUrl) {
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: 'ssemble-ai-clipping',
|
|
27
|
+
version: '1.0.0',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const client = new SsembleClient(apiKey, baseUrl);
|
|
31
|
+
|
|
32
|
+
// Register all 9 tools
|
|
33
|
+
registerCreateShort(server, client);
|
|
34
|
+
registerGetStatus(server, client);
|
|
35
|
+
registerGetShorts(server, client);
|
|
36
|
+
registerListRequests(server, client);
|
|
37
|
+
registerDeleteRequest(server, client);
|
|
38
|
+
registerListTemplates(server, client);
|
|
39
|
+
registerListMusic(server, client);
|
|
40
|
+
registerListGameVideos(server, client);
|
|
41
|
+
registerListMemeHooks(server, client);
|
|
42
|
+
|
|
43
|
+
// Register resources
|
|
44
|
+
registerResources(server);
|
|
45
|
+
|
|
46
|
+
// Register prompts
|
|
47
|
+
registerPrompts(server);
|
|
48
|
+
|
|
49
|
+
return server;
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Prompts — pre-built workflow templates.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function registerPrompts(server) {
|
|
6
|
+
server.prompt(
|
|
7
|
+
'create-viral-short',
|
|
8
|
+
'Guided workflow for creating an optimized viral short from a YouTube video',
|
|
9
|
+
[
|
|
10
|
+
{ name: 'url', description: 'YouTube video URL', required: true },
|
|
11
|
+
{ name: 'style', description: 'Style preference: engaging, educational, funny, dramatic', required: false },
|
|
12
|
+
],
|
|
13
|
+
({ url, style }) => {
|
|
14
|
+
const styleHint = style ? `\nThe user prefers a **${style}** style.` : '';
|
|
15
|
+
return {
|
|
16
|
+
messages: [
|
|
17
|
+
{
|
|
18
|
+
role: 'user',
|
|
19
|
+
content: {
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: `I want to create viral short-form clips from this YouTube video: ${url}${styleHint}
|
|
22
|
+
|
|
23
|
+
Please help me by:
|
|
24
|
+
1. First, use \`list_templates\` to show me available caption styles
|
|
25
|
+
2. Use \`list_music\` to suggest background music options
|
|
26
|
+
3. Ask me which template and music I prefer
|
|
27
|
+
4. Then use \`create_short\` with my choices (use start: 0, end: 600 for the first 10 minutes, or ask me for a specific time range)
|
|
28
|
+
5. After submitting, remind me to check back with \`get_status\` in a few minutes`,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
server.prompt(
|
|
37
|
+
'batch-process-videos',
|
|
38
|
+
'Template for processing multiple YouTube videos into shorts',
|
|
39
|
+
[
|
|
40
|
+
{ name: 'urls', description: 'Comma-separated YouTube URLs', required: true },
|
|
41
|
+
],
|
|
42
|
+
({ urls }) => {
|
|
43
|
+
const urlList = urls.split(',').map((u) => u.trim()).filter(Boolean);
|
|
44
|
+
return {
|
|
45
|
+
messages: [
|
|
46
|
+
{
|
|
47
|
+
role: 'user',
|
|
48
|
+
content: {
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: `I want to create shorts from these ${urlList.length} videos:
|
|
51
|
+
${urlList.map((u, i) => `${i + 1}. ${u}`).join('\n')}
|
|
52
|
+
|
|
53
|
+
Please:
|
|
54
|
+
1. Submit each video using \`create_short\` with default settings (start: 0, end: 600, preferredLength: under60sec)
|
|
55
|
+
2. Track all request IDs
|
|
56
|
+
3. After all are submitted, list the request IDs so I can check on them later with \`get_status\``,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resources — static reference data for AI context.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_LANGUAGES = {
|
|
6
|
+
en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian',
|
|
7
|
+
pt: 'Portuguese', ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese',
|
|
8
|
+
ar: 'Arabic', hi: 'Hindi', tr: 'Turkish', pl: 'Polish', nl: 'Dutch',
|
|
9
|
+
sv: 'Swedish', no: 'Norwegian', da: 'Danish', fi: 'Finnish', cs: 'Czech',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const PREFERRED_LENGTHS = {
|
|
13
|
+
under30sec: 'Under 30 seconds — quick hook clips',
|
|
14
|
+
under60sec: 'Under 60 seconds (default) — standard TikTok/Reels length',
|
|
15
|
+
under90sec: 'Under 90 seconds — extended shorts',
|
|
16
|
+
under3min: 'Under 3 minutes — YouTube Shorts max',
|
|
17
|
+
under5min: 'Under 5 minutes — longer highlight clips',
|
|
18
|
+
under10min: 'Under 10 minutes — full segment extraction',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const LAYOUTS = {
|
|
22
|
+
auto: 'Automatic — AI detects best framing based on speaker position',
|
|
23
|
+
fill: 'Fill — crop to fill 9:16 vertical frame',
|
|
24
|
+
fit: 'Fit — fit entire frame with bars if needed',
|
|
25
|
+
square: 'Square — 1:1 aspect ratio',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function registerResources(server) {
|
|
29
|
+
server.resource(
|
|
30
|
+
'supported-languages',
|
|
31
|
+
'ssemble://supported-languages',
|
|
32
|
+
'List of supported language codes and names for video transcription and captions',
|
|
33
|
+
async () => {
|
|
34
|
+
let text = '## Supported Languages\n\n';
|
|
35
|
+
text += '| Code | Language |\n|------|----------|\n';
|
|
36
|
+
for (const [code, name] of Object.entries(SUPPORTED_LANGUAGES)) {
|
|
37
|
+
text += `| ${code} | ${name} |\n`;
|
|
38
|
+
}
|
|
39
|
+
text += '\nUse the 2-letter code in the `language` or `captionLanguage` parameter.';
|
|
40
|
+
return { contents: [{ uri: 'ssemble://supported-languages', mimeType: 'text/markdown', text }] };
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
server.resource(
|
|
45
|
+
'preferred-lengths',
|
|
46
|
+
'ssemble://preferred-lengths',
|
|
47
|
+
'Available clip duration options for the preferredLength parameter',
|
|
48
|
+
async () => {
|
|
49
|
+
let text = '## Preferred Length Options\n\n';
|
|
50
|
+
text += '| Value | Description |\n|-------|-------------|\n';
|
|
51
|
+
for (const [value, desc] of Object.entries(PREFERRED_LENGTHS)) {
|
|
52
|
+
text += `| \`${value}\` | ${desc} |\n`;
|
|
53
|
+
}
|
|
54
|
+
text += '\nDefault: `under60sec`';
|
|
55
|
+
return { contents: [{ uri: 'ssemble://preferred-lengths', mimeType: 'text/markdown', text }] };
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
server.resource(
|
|
60
|
+
'layouts',
|
|
61
|
+
'ssemble://layouts',
|
|
62
|
+
'Available video layout/framing options',
|
|
63
|
+
async () => {
|
|
64
|
+
let text = '## Layout Options\n\n';
|
|
65
|
+
text += '| Value | Description |\n|-------|-------------|\n';
|
|
66
|
+
for (const [value, desc] of Object.entries(LAYOUTS)) {
|
|
67
|
+
text += `| \`${value}\` | ${desc} |\n`;
|
|
68
|
+
}
|
|
69
|
+
text += '\nDefault: `auto`';
|
|
70
|
+
return { contents: [{ uri: 'ssemble://layouts', mimeType: 'text/markdown', text }] };
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatCreateShortResponse, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const SUPPORTED_LANGUAGES = [
|
|
5
|
+
'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'zh',
|
|
6
|
+
'ar', 'hi', 'tr', 'pl', 'nl', 'sv', 'no', 'da', 'fi', 'cs',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const schema = {
|
|
10
|
+
url: z.string().describe('YouTube video URL').optional(),
|
|
11
|
+
fileUrl: z.string().url().describe('Public video file URL (alternative to url)').optional(),
|
|
12
|
+
start: z.number().min(0).describe('Start time in seconds (>= 0)'),
|
|
13
|
+
end: z.number().min(1).describe('End time in seconds (> start, max 1200s window)'),
|
|
14
|
+
preferredLength: z.enum(['under30sec', 'under60sec', 'under90sec', 'under3min', 'under5min', 'under10min'])
|
|
15
|
+
.default('under60sec').describe('Target clip duration').optional(),
|
|
16
|
+
language: z.enum(SUPPORTED_LANGUAGES).default('en').describe('Spoken language (ISO 639-1)').optional(),
|
|
17
|
+
captionLanguage: z.enum(SUPPORTED_LANGUAGES).describe('Caption language if different from spoken').optional(),
|
|
18
|
+
templateId: z.string().regex(/^[0-9a-fA-F]{24}$/).describe('Caption template ID from list_templates (24-char hex)').optional(),
|
|
19
|
+
noClipping: z.boolean().default(false).describe('Skip AI clipping, process entire range as one clip').optional(),
|
|
20
|
+
hookTitle: z.boolean().default(false).describe('Add animated hook title at start').optional(),
|
|
21
|
+
memeHook: z.boolean().default(false).describe('Prepend a meme hook clip (2-5s attention grabber)').optional(),
|
|
22
|
+
memeHookName: z.string().describe('Exact meme hook name from list_meme_hooks (case-sensitive)').optional(),
|
|
23
|
+
gameVideo: z.boolean().default(false).describe('Add split-screen gameplay overlay').optional(),
|
|
24
|
+
gameVideoName: z.string().describe('Exact game video name from list_game_videos (case-sensitive)').optional(),
|
|
25
|
+
ctaEnabled: z.boolean().default(false).describe('Show call-to-action text overlay').optional(),
|
|
26
|
+
ctaText: z.string().max(200).describe('CTA text (max 200 chars, required when ctaEnabled=true)').optional(),
|
|
27
|
+
music: z.boolean().default(false).describe('Add background music').optional(),
|
|
28
|
+
musicName: z.string().describe('Exact track name from list_music (case-sensitive)').optional(),
|
|
29
|
+
musicVolume: z.number().min(0).max(100).default(10).describe('Music volume 0-100').optional(),
|
|
30
|
+
layout: z.enum(['auto', 'fill', 'fit', 'square']).default('auto').describe('Video framing layout').optional(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function registerCreateShort(server, client) {
|
|
34
|
+
server.tool(
|
|
35
|
+
'create_short',
|
|
36
|
+
'Create AI-generated short-form video clips from a YouTube video or uploaded file. Returns a request ID instantly. Processing takes 5-30 minutes. Costs 1 credit.',
|
|
37
|
+
schema,
|
|
38
|
+
async (params) => {
|
|
39
|
+
if (!params.url && !params.fileUrl) {
|
|
40
|
+
return { content: [{ type: 'text', text: formatError({ message: 'Either url or fileUrl is required', code: 'invalid_request' }) }], isError: true };
|
|
41
|
+
}
|
|
42
|
+
if (params.url && params.fileUrl) {
|
|
43
|
+
return { content: [{ type: 'text', text: formatError({ message: 'Provide either url or fileUrl, not both', code: 'invalid_request' }) }], isError: true };
|
|
44
|
+
}
|
|
45
|
+
if (params.start >= params.end) {
|
|
46
|
+
return { content: [{ type: 'text', text: formatError({ message: 'Start time must be less than end time', code: 'invalid_request' }) }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
if ((params.end - params.start) > 1200) {
|
|
49
|
+
return { content: [{ type: 'text', text: formatError({ message: 'Time window exceeds maximum of 1200 seconds (20 minutes)', code: 'invalid_request' }) }], isError: true };
|
|
50
|
+
}
|
|
51
|
+
if (params.ctaEnabled && !params.ctaText) {
|
|
52
|
+
return { content: [{ type: 'text', text: formatError({ message: 'ctaText is required when ctaEnabled is true', code: 'invalid_request' }) }], isError: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const result = await client.createShort(params);
|
|
57
|
+
return { content: [{ type: 'text', text: formatCreateShortResponse(result) }] };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatDeleteResponse, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
requestId: z.string().regex(/^[0-9a-fA-F]{24}$/).describe('The request ID to delete (24-char hex). This action is irreversible and credits are NOT refunded.'),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function registerDeleteRequest(server, client) {
|
|
9
|
+
server.tool(
|
|
10
|
+
'delete_request',
|
|
11
|
+
'Permanently delete a short creation request and all generated videos. Credits are NOT refunded. This action is irreversible.',
|
|
12
|
+
schema,
|
|
13
|
+
async ({ requestId }) => {
|
|
14
|
+
try {
|
|
15
|
+
const result = await client.deleteRequest(requestId);
|
|
16
|
+
return { content: [{ type: 'text', text: formatDeleteResponse(result) }] };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatShortsResponse, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
requestId: z.string().regex(/^[0-9a-fA-F]{24}$/).describe('The request ID from create_short (24-char hex)'),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function registerGetShorts(server, client) {
|
|
9
|
+
server.tool(
|
|
10
|
+
'get_shorts',
|
|
11
|
+
'Retrieve all generated short clips for a completed request. Returns video URLs, AI-generated titles, descriptions, viral scores, and durations sorted by viral score.',
|
|
12
|
+
schema,
|
|
13
|
+
async ({ requestId }) => {
|
|
14
|
+
try {
|
|
15
|
+
const result = await client.getShorts(requestId);
|
|
16
|
+
return { content: [{ type: 'text', text: formatShortsResponse(result) }] };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatStatusResponse, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
requestId: z.string().regex(/^[0-9a-fA-F]{24}$/).describe('The request ID from create_short (24-char hex)'),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function registerGetStatus(server, client) {
|
|
9
|
+
server.tool(
|
|
10
|
+
'get_status',
|
|
11
|
+
'Check processing status and progress of a short creation request. Returns status (queued/processing/completed/failed), progress percentage, and current step.',
|
|
12
|
+
schema,
|
|
13
|
+
async ({ requestId }) => {
|
|
14
|
+
try {
|
|
15
|
+
const result = await client.getStatus(requestId);
|
|
16
|
+
return { content: [{ type: 'text', text: formatStatusResponse(result) }] };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatPaginatedAssets, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
page: z.number().min(1).default(1).describe('Page number').optional(),
|
|
6
|
+
limit: z.number().min(1).max(100).default(20).describe('Items per page (1-100)').optional(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function registerListGameVideos(server, client) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'list_game_videos',
|
|
12
|
+
'List available gameplay videos for split-screen overlays (content top, game bottom). Use the exact gameVideoName when creating shorts.',
|
|
13
|
+
schema,
|
|
14
|
+
async (params) => {
|
|
15
|
+
try {
|
|
16
|
+
const result = await client.listGameVideos(params);
|
|
17
|
+
return { content: [{ type: 'text', text: formatPaginatedAssets(result, 'gameVideos', 'Gameplay Overlays') }] };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatPaginatedAssets, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
page: z.number().min(1).default(1).describe('Page number').optional(),
|
|
6
|
+
limit: z.number().min(1).max(100).default(20).describe('Items per page (1-100)').optional(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function registerListMemeHooks(server, client) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'list_meme_hooks',
|
|
12
|
+
'List available meme hook clips (2-5 second attention grabbers prepended to shorts). Use the exact memeHookName when creating shorts.',
|
|
13
|
+
schema,
|
|
14
|
+
async (params) => {
|
|
15
|
+
try {
|
|
16
|
+
const result = await client.listMemeHooks(params);
|
|
17
|
+
return { content: [{ type: 'text', text: formatPaginatedAssets(result, 'memeHooks', 'Meme Hooks') }] };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatPaginatedAssets, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
page: z.number().min(1).default(1).describe('Page number').optional(),
|
|
6
|
+
limit: z.number().min(1).max(100).default(20).describe('Items per page (1-100)').optional(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function registerListMusic(server, client) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'list_music',
|
|
12
|
+
'List available background music tracks with names and durations. Use the exact musicName when creating shorts.',
|
|
13
|
+
schema,
|
|
14
|
+
async (params) => {
|
|
15
|
+
try {
|
|
16
|
+
const result = await client.listMusic(params);
|
|
17
|
+
return { content: [{ type: 'text', text: formatPaginatedAssets(result, 'music', 'Background Music') }] };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatListRequestsResponse, formatError } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
page: z.number().min(1).default(1).describe('Page number').optional(),
|
|
6
|
+
limit: z.number().min(1).max(100).default(20).describe('Items per page (1-100)').optional(),
|
|
7
|
+
status: z.enum(['queued', 'processing', 'completed', 'failed', 'cancelled']).describe('Filter by status').optional(),
|
|
8
|
+
sortBy: z.enum(['createdAt', 'updatedAt']).default('createdAt').describe('Sort field').optional(),
|
|
9
|
+
sortOrder: z.enum(['asc', 'desc']).default('desc').describe('Sort direction').optional(),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function registerListRequests(server, client) {
|
|
13
|
+
server.tool(
|
|
14
|
+
'list_requests',
|
|
15
|
+
'List all short creation requests with optional status filtering, pagination, and sorting.',
|
|
16
|
+
schema,
|
|
17
|
+
async (params) => {
|
|
18
|
+
try {
|
|
19
|
+
const result = await client.listRequests(params);
|
|
20
|
+
return { content: [{ type: 'text', text: formatListRequestsResponse(result) }] };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { formatTemplatesResponse, formatError } from '../utils/format.js';
|
|
2
|
+
|
|
3
|
+
export function registerListTemplates(server, client) {
|
|
4
|
+
server.tool(
|
|
5
|
+
'list_templates',
|
|
6
|
+
'List all available caption style templates with IDs and names. Use the templateId when creating shorts.',
|
|
7
|
+
{},
|
|
8
|
+
async () => {
|
|
9
|
+
try {
|
|
10
|
+
const result = await client.listTemplates();
|
|
11
|
+
return { content: [{ type: 'text', text: formatTemplatesResponse(result) }] };
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return { content: [{ type: 'text', text: formatError(error) }], isError: true };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Streamable HTTP transport — entry point for remote deployment.
|
|
5
|
+
* Runs an Express server that handles MCP protocol via HTTP.
|
|
6
|
+
* Supports per-request API keys for multi-tenant usage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { createSsembleMcpServer } from '../index.js';
|
|
12
|
+
|
|
13
|
+
const app = express();
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
|
|
16
|
+
const SERVER_CARD = {
|
|
17
|
+
serverInfo: {
|
|
18
|
+
name: 'ssemble-ai-clipping',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
},
|
|
21
|
+
capabilities: {
|
|
22
|
+
tools: true,
|
|
23
|
+
resources: true,
|
|
24
|
+
prompts: true,
|
|
25
|
+
},
|
|
26
|
+
authentication: {
|
|
27
|
+
required: true,
|
|
28
|
+
schemes: ['api_key'],
|
|
29
|
+
instructions: 'Pass your Ssemble API key via the X-Ssemble-API-Key header or set SSEMBLE_API_KEY env var on the server.',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Health check
|
|
34
|
+
app.get('/health', (_req, res) => {
|
|
35
|
+
res.json({ status: 'ok', server: 'ssemble-mcp', version: '1.0.0' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Smithery auto-discovery
|
|
39
|
+
app.get('/.well-known/mcp/server-card.json', (_req, res) => {
|
|
40
|
+
res.json(SERVER_CARD);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// MCP endpoint — stateless mode (each request is independent)
|
|
44
|
+
app.all('/mcp', async (req, res) => {
|
|
45
|
+
const apiKey = req.headers['x-ssemble-api-key'] || process.env.SSEMBLE_API_KEY;
|
|
46
|
+
|
|
47
|
+
if (!apiKey) {
|
|
48
|
+
return res.status(401).json({
|
|
49
|
+
error: {
|
|
50
|
+
code: 'missing_api_key',
|
|
51
|
+
message: 'Ssemble API key required. Pass via X-Ssemble-API-Key header or set SSEMBLE_API_KEY env var.',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const baseUrl = process.env.SSEMBLE_API_BASE_URL;
|
|
58
|
+
const server = createSsembleMcpServer(apiKey, baseUrl);
|
|
59
|
+
const transport = new StreamableHTTPServerTransport({
|
|
60
|
+
sessionIdGenerator: undefined, // stateless
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await server.connect(transport);
|
|
64
|
+
await transport.handleRequest(req, res, req.body);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!res.headersSent) {
|
|
67
|
+
res.status(500).json({
|
|
68
|
+
error: {
|
|
69
|
+
code: 'internal_error',
|
|
70
|
+
message: 'MCP server error',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const port = parseInt(process.env.MCP_PORT || '3100', 10);
|
|
78
|
+
app.listen(port, () => {
|
|
79
|
+
console.log(`Ssemble MCP Server (HTTP) listening on port ${port}`);
|
|
80
|
+
console.log(` MCP endpoint: http://localhost:${port}/mcp`);
|
|
81
|
+
console.log(` Health check: http://localhost:${port}/health`);
|
|
82
|
+
console.log(` Server card: http://localhost:${port}/.well-known/mcp/server-card.json`);
|
|
83
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stdio transport — entry point for local usage via npx or Claude Desktop/Code config.
|
|
5
|
+
* Reads SSEMBLE_API_KEY from environment variable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { createSsembleMcpServer } from '../index.js';
|
|
10
|
+
|
|
11
|
+
const apiKey = process.env.SSEMBLE_API_KEY;
|
|
12
|
+
if (!apiKey) {
|
|
13
|
+
console.error('Error: SSEMBLE_API_KEY environment variable is required.');
|
|
14
|
+
console.error('Get your API key at https://app.ssemble.com → Settings → API Keys');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const baseUrl = process.env.SSEMBLE_API_BASE_URL;
|
|
19
|
+
const server = createSsembleMcpServer(apiKey, baseUrl);
|
|
20
|
+
const transport = new StdioServerTransport();
|
|
21
|
+
|
|
22
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response formatting utilities for AI readability.
|
|
3
|
+
* Converts API responses into markdown that AI assistants can present clearly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function formatRateLimits(rateLimits) {
|
|
7
|
+
if (!rateLimits || !rateLimits.hourlyRemaining) return '';
|
|
8
|
+
return `\n\n---\nRate limits: ${rateLimits.hourlyRemaining}/${rateLimits.hourlyLimit} hourly, ${rateLimits.dailyRemaining}/${rateLimits.dailyLimit} daily`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatCreateShortResponse(result) {
|
|
12
|
+
const d = result.data;
|
|
13
|
+
let text = `## Short Creation Submitted\n\n`;
|
|
14
|
+
text += `- **Request ID**: \`${d.requestId}\`\n`;
|
|
15
|
+
text += `- **Status**: ${d.status}\n`;
|
|
16
|
+
text += `- **Credits Used**: ${d.creditsUsed}\n`;
|
|
17
|
+
text += `- **Estimated Completion**: ${d.estimatedCompletionTime}\n`;
|
|
18
|
+
text += `\nProcessing takes 5-30 minutes. Use \`get_status\` with request ID \`${d.requestId}\` to check progress.`;
|
|
19
|
+
|
|
20
|
+
if (d.timeAdjustment) {
|
|
21
|
+
text += `\n\n**Note**: ${d.timeAdjustment.message}\n`;
|
|
22
|
+
text += `- Original: ${d.timeAdjustment.original.start}s - ${d.timeAdjustment.original.end}s\n`;
|
|
23
|
+
text += `- Adjusted: ${d.timeAdjustment.adjusted.start}s - ${d.timeAdjustment.adjusted.end}s\n`;
|
|
24
|
+
text += `- Video duration: ${d.timeAdjustment.videoDuration}s\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
text += formatRateLimits(result.rateLimits);
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatStatusResponse(result) {
|
|
32
|
+
const d = result.data;
|
|
33
|
+
const progressBar = makeProgressBar(d.progress || 0);
|
|
34
|
+
|
|
35
|
+
let text = `## Request Status: \`${d._id}\`\n\n`;
|
|
36
|
+
text += `- **Status**: ${d.status}\n`;
|
|
37
|
+
text += `- **Step**: ${d.step}\n`;
|
|
38
|
+
text += `- **Progress**: ${progressBar} ${d.progress || 0}%\n`;
|
|
39
|
+
if (d.stepMessage) {
|
|
40
|
+
text += `- **Message**: ${d.stepMessage}\n`;
|
|
41
|
+
}
|
|
42
|
+
if (d.failureReason) {
|
|
43
|
+
text += `- **Failure Reason**: ${d.failureReason}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (d.status === 'completed') {
|
|
47
|
+
text += `\nUse \`get_shorts\` with request ID \`${d._id}\` to retrieve the generated clips.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
text += formatRateLimits(result.rateLimits);
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatShortsResponse(result) {
|
|
55
|
+
const d = result.data;
|
|
56
|
+
|
|
57
|
+
if (d.status !== 'completed' && d.status !== 'TASK_COMPLETE') {
|
|
58
|
+
return `Request \`${d._id}\` is not yet completed (status: ${d.status}). Use \`get_status\` to check progress.`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let text = `## Generated Shorts for "${d.title || 'Untitled'}"\n\n`;
|
|
62
|
+
text += `- **Source**: ${d.url || 'uploaded file'}\n`;
|
|
63
|
+
text += `- **Time Range**: ${d.start}s - ${d.end}s\n`;
|
|
64
|
+
text += `- **Language**: ${d.language}\n`;
|
|
65
|
+
text += `- **Template**: ${d.template}\n\n`;
|
|
66
|
+
|
|
67
|
+
const shorts = d.shorts || [];
|
|
68
|
+
if (shorts.length === 0) {
|
|
69
|
+
text += `No shorts were generated.\n`;
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Sort by viral_score descending
|
|
74
|
+
const sorted = [...shorts].sort((a, b) => (b.viral_score || 0) - (a.viral_score || 0));
|
|
75
|
+
|
|
76
|
+
text += `| # | Title | Duration | Viral Score | Video URL |\n`;
|
|
77
|
+
text += `|---|-------|----------|-------------|----------|\n`;
|
|
78
|
+
|
|
79
|
+
sorted.forEach((short, i) => {
|
|
80
|
+
const title = (short.title || 'Untitled').substring(0, 40);
|
|
81
|
+
const duration = short.duration ? `${short.duration}s` : 'N/A';
|
|
82
|
+
const score = short.viral_score != null ? short.viral_score : 'N/A';
|
|
83
|
+
const url = short.video_url || (short.error ? 'Error' : 'Processing...');
|
|
84
|
+
text += `| ${i + 1} | ${title} | ${duration} | ${score} | ${url} |\n`;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
text += `\n**${sorted.length} clips generated**, sorted by viral score.`;
|
|
88
|
+
text += formatRateLimits(result.rateLimits);
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatListRequestsResponse(result) {
|
|
93
|
+
const d = result.data;
|
|
94
|
+
const requests = d.requests || [];
|
|
95
|
+
const p = d.pagination;
|
|
96
|
+
|
|
97
|
+
if (requests.length === 0) {
|
|
98
|
+
return `No requests found.${formatRateLimits(result.rateLimits)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let text = `## Your Requests (Page ${p.page}/${p.totalPages}, ${p.totalCount} total)\n\n`;
|
|
102
|
+
text += `| # | Request ID | Status | Progress | Created | Duration |\n`;
|
|
103
|
+
text += `|---|-----------|--------|----------|---------|----------|\n`;
|
|
104
|
+
|
|
105
|
+
requests.forEach((req, i) => {
|
|
106
|
+
const created = req.createdAt ? new Date(req.createdAt).toLocaleDateString() : 'N/A';
|
|
107
|
+
const duration = req.duration ? `${req.duration}s` : 'N/A';
|
|
108
|
+
text += `| ${(p.page - 1) * p.limit + i + 1} | \`${req.requestId}\` | ${req.status} | ${req.progress || 0}% | ${created} | ${duration} |\n`;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (p.totalPages > 1) {
|
|
112
|
+
text += `\nPage ${p.page} of ${p.totalPages}. Use \`page\` parameter to browse more.`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
text += formatRateLimits(result.rateLimits);
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function formatTemplatesResponse(result) {
|
|
120
|
+
const d = result.data;
|
|
121
|
+
const templates = d.templates || [];
|
|
122
|
+
|
|
123
|
+
let text = `## Caption Templates (${templates.length} available)\n\n`;
|
|
124
|
+
text += `| Template ID | Name | Default |\n`;
|
|
125
|
+
text += `|-------------|------|--------|\n`;
|
|
126
|
+
|
|
127
|
+
templates.forEach((t) => {
|
|
128
|
+
const isDefault = t.templateId === d.defaultTemplateId ? 'Yes' : '';
|
|
129
|
+
text += `| \`${t.templateId}\` | ${t.displayName || t.name} | ${isDefault} |\n`;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
text += `\nDefault template: **${d.defaultTemplateName}** (\`${d.defaultTemplateId}\`)`;
|
|
133
|
+
text += `\nUse \`templateId\` when calling \`create_short\`.`;
|
|
134
|
+
text += formatRateLimits(result.rateLimits);
|
|
135
|
+
return text;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function formatPaginatedAssets(result, assetKey, assetLabel) {
|
|
139
|
+
const d = result.data;
|
|
140
|
+
const items = d[assetKey] || [];
|
|
141
|
+
const p = d.pagination;
|
|
142
|
+
|
|
143
|
+
if (items.length === 0) {
|
|
144
|
+
return `No ${assetLabel} found.${formatRateLimits(result.rateLimits)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let text = `## ${assetLabel} (Page ${p.page}/${p.totalPages}, ${p.totalCount} total)\n\n`;
|
|
148
|
+
text += `| # | Name |${items[0]?.duration != null ? ' Duration |' : ''}\n`;
|
|
149
|
+
text += `|---|------|${items[0]?.duration != null ? '----------|' : ''}\n`;
|
|
150
|
+
|
|
151
|
+
items.forEach((item, i) => {
|
|
152
|
+
const num = (p.page - 1) * p.limit + i + 1;
|
|
153
|
+
const durationCol = item.duration != null ? ` ${item.duration}s |` : '';
|
|
154
|
+
text += `| ${num} | ${item.name} |${durationCol}\n`;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (p.totalPages > 1) {
|
|
158
|
+
text += `\nPage ${p.page} of ${p.totalPages}. Use \`page\` parameter to browse more.`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
text += `\nUse the exact \`name\` value when calling \`create_short\`.`;
|
|
162
|
+
text += formatRateLimits(result.rateLimits);
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatDeleteResponse(result) {
|
|
167
|
+
let text = `Request successfully deleted. Credits are NOT refunded.`;
|
|
168
|
+
text += formatRateLimits(result.rateLimits);
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function formatError(error) {
|
|
173
|
+
if (error instanceof Error && error.code) {
|
|
174
|
+
let text = `**Error**: ${error.message}`;
|
|
175
|
+
if (error.code) text += ` (${error.code})`;
|
|
176
|
+
if (error.details) {
|
|
177
|
+
text += `\n\n**Details**: ${JSON.stringify(error.details, null, 2)}`;
|
|
178
|
+
}
|
|
179
|
+
if (error.rateLimits?.hourlyRemaining) {
|
|
180
|
+
text += formatRateLimits(error.rateLimits);
|
|
181
|
+
}
|
|
182
|
+
return text;
|
|
183
|
+
}
|
|
184
|
+
return `**Error**: ${error.message || String(error)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function makeProgressBar(percent) {
|
|
188
|
+
const filled = Math.round(percent / 10);
|
|
189
|
+
const empty = 10 - filled;
|
|
190
|
+
return '[' + '='.repeat(filled) + ' '.repeat(empty) + ']';
|
|
191
|
+
}
|