streamby-core 0.35.0 → 0.36.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/CHANGELOG.md +30 -0
- package/README.md +93 -0
- package/dist/adapters/database/connectionManager.js +23 -5
- package/dist/mcp/audit.d.ts +12 -0
- package/dist/mcp/audit.js +17 -0
- package/dist/mcp/rateLimit.d.ts +14 -0
- package/dist/mcp/rateLimit.js +21 -0
- package/dist/mcp/server.d.ts +14 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/tools.d.ts +17 -0
- package/dist/mcp/tools.js +227 -0
- package/dist/middleware/createRouter.js +3 -0
- package/dist/middleware/routes/mcp.d.ts +3 -0
- package/dist/middleware/routes/mcp.js +62 -0
- package/dist/services/auth.js +3 -0
- package/dist/types/index.d.ts +7 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.36.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **MCP server** at `POST /mcp`, for agents such as Claude Code to work with StreamBy projects
|
|
8
|
+
with an API token (see the README).
|
|
9
|
+
- It takes only an API token.
|
|
10
|
+
- Its tools call the REST API with that token, so they reach exactly what the token and its
|
|
11
|
+
owner can, and writes are saved as the UI's are.
|
|
12
|
+
- **Reading tools:** projects, assets with their metadata and rights, workflow, pipelines,
|
|
13
|
+
exports, jobs, and production sequences, shots and tasks.
|
|
14
|
+
- **Writing tools**, listed only for `write` tokens: asset metadata and rights, pipelines,
|
|
15
|
+
sequences, shots and tasks.
|
|
16
|
+
- **`mcp.rateLimit`** (default 120 requests a minute per token; past it, 429 with
|
|
17
|
+
`Retry-After`) and **`mcp.apiBaseUrl`** in the router config.
|
|
18
|
+
- **New table: `mcp_audit`.** It's created at startup in the main database. Every tool call is
|
|
19
|
+
recorded there, with the user, token, project, tool and result, but never the arguments nor
|
|
20
|
+
the token.
|
|
21
|
+
- New dependencies: `@modelcontextprotocol/sdk` and `zod`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Projects can be made with PostgreSQL as the main database.** The `projects` table that
|
|
26
|
+
StreamBy creates at startup was missing columns the routes write: `public`, `category`,
|
|
27
|
+
`integrationConnections`, `distributionConnections` and `renderFarmConnections`.
|
|
28
|
+
- Making a project failed with `column "public" does not exist`.
|
|
29
|
+
- Adding service, distribution or render farm connections failed too.
|
|
30
|
+
- They're added at startup now, only where they're missing; databases that already have
|
|
31
|
+
them aren't changed.
|
|
32
|
+
|
|
3
33
|
## 0.35.0
|
|
4
34
|
|
|
5
35
|
### Added
|
package/README.md
CHANGED
|
@@ -149,6 +149,99 @@ its owner can, and it stops reaching a project as soon as its owner leaves it.
|
|
|
149
149
|
|
|
150
150
|
---
|
|
151
151
|
|
|
152
|
+
## 🤖 MCP server
|
|
153
|
+
|
|
154
|
+
StreamBy serves an [MCP](https://modelcontextprotocol.io) server at `POST /streamby/mcp`
|
|
155
|
+
(Streamable HTTP, stateless). Agents such as Claude Code use it to read and work on StreamBy
|
|
156
|
+
projects with an API token (see above). It takes only an API token; a browser session gets 401.
|
|
157
|
+
|
|
158
|
+
Each tool calls StreamBy's own REST API with the caller's token. So it reaches exactly what the
|
|
159
|
+
token's scope and its owner's role in each project allow, and a write is saved just like one
|
|
160
|
+
made from the UI, which shows it when it loads the project.
|
|
161
|
+
|
|
162
|
+
**Tools**
|
|
163
|
+
|
|
164
|
+
- **Reading**, with any token:
|
|
165
|
+
- `list_projects`, `get_project`;
|
|
166
|
+
- `list_assets` (by category, optionally in one storage connection, and filtered by name),
|
|
167
|
+
`get_asset_metadata`, `get_asset_rights`;
|
|
168
|
+
- `get_workflow`, `list_pipelines`, `get_pipeline`, `list_exports`, `get_export`;
|
|
169
|
+
- `get_job`;
|
|
170
|
+
- `list_sequences`, `list_shots`, `list_tasks`.
|
|
171
|
+
- **Writing**, listed only for a `write` token, and allowed only where its owner is an editor or
|
|
172
|
+
an admin:
|
|
173
|
+
- `update_asset_metadata`, `set_asset_rights`;
|
|
174
|
+
- `create_pipeline`, `update_pipeline`;
|
|
175
|
+
- `create_sequence`, `update_sequence`, `create_shot`, `update_shot`, `create_task`,
|
|
176
|
+
`update_task`.
|
|
177
|
+
|
|
178
|
+
**Limits and audit**
|
|
179
|
+
|
|
180
|
+
- Each token can make at most 120 requests a minute (a sliding window). Past that, StreamBy
|
|
181
|
+
answers 429 with `Retry-After`. Each instance counts its own requests. Change the limit with
|
|
182
|
+
`mcp.rateLimit`.
|
|
183
|
+
- Every tool call is recorded in the `mcp_audit` table, created at startup in the main database.
|
|
184
|
+
Each row has the user, the token's id, the project, the tool, whether it went through, the
|
|
185
|
+
status, the error and the duration. A row never includes the arguments or the token.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
createStreamByRouter({
|
|
189
|
+
// ...
|
|
190
|
+
mcp: {
|
|
191
|
+
rateLimit: { requests: 120, windowMs: 60_000 }, // the default
|
|
192
|
+
// apiBaseUrl: 'http://127.0.0.1:3000/streamby', // where tools reach the REST API;
|
|
193
|
+
// by default this same process, at the request's local port and mount path
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Connecting from Claude Code
|
|
199
|
+
|
|
200
|
+
Make an API token in StreamBy (Settings → Security → Personal API tokens), and keep it in an
|
|
201
|
+
environment variable, never in a file:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
export STREAMBY_TOKEN=sbt_...
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Add the server to your project's `.mcp.json`. Claude Code expands `${STREAMBY_TOKEN}` from the
|
|
208
|
+
environment:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"mcpServers": {
|
|
213
|
+
"streamby": {
|
|
214
|
+
"type": "http",
|
|
215
|
+
"url": "https://your-host/streamby/mcp",
|
|
216
|
+
"headers": { "Authorization": "Bearer ${STREAMBY_TOKEN}" }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Connecting from Claude Desktop
|
|
223
|
+
|
|
224
|
+
Claude Desktop runs local (stdio) servers from `claude_desktop_config.json`. Reach StreamBy
|
|
225
|
+
through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), passing the token in its
|
|
226
|
+
`env`. This file holds it in plain text, so use a token with only the scope you need, and
|
|
227
|
+
an expiry:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"mcpServers": {
|
|
232
|
+
"streamby": {
|
|
233
|
+
"command": "npx",
|
|
234
|
+
"args": ["mcp-remote", "https://your-host/streamby/mcp", "--header", "Authorization:${STREAMBY_AUTH}"],
|
|
235
|
+
"env": { "STREAMBY_AUTH": "Bearer sbt_..." }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The claude.ai connector needs OAuth, which isn't available yet: it's for API tokens only for now.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
152
245
|
## 👥 Project roles
|
|
153
246
|
|
|
154
247
|
What a user can do in a project depends on their role in that project, as an active member
|
|
@@ -80,11 +80,12 @@ const ensureTablesExist = async (pool) => {
|
|
|
80
80
|
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS workflow JSONB;
|
|
81
81
|
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS pipelines JSONB DEFAULT '[]';
|
|
82
82
|
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "builtinBackfilledAt" TIMESTAMP WITH TIME ZONE;
|
|
83
|
-
--
|
|
84
|
-
|
|
85
|
-
ALTER TABLE streamby.projects
|
|
86
|
-
ALTER TABLE streamby.projects
|
|
87
|
-
ALTER TABLE streamby.projects
|
|
83
|
+
-- Written when a project is made (routes/project) and by its connection routes.
|
|
84
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS public BOOLEAN DEFAULT TRUE;
|
|
85
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS category TEXT;
|
|
86
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "integrationConnections" JSONB DEFAULT '[]';
|
|
87
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "distributionConnections" JSONB DEFAULT '[]';
|
|
88
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "renderFarmConnections" JSONB DEFAULT '[]';
|
|
88
89
|
`);
|
|
89
90
|
// Ensure dbType exists with a non-null default
|
|
90
91
|
await pool.query(`
|
|
@@ -165,6 +166,23 @@ const ensureTablesExist = async (pool) => {
|
|
|
165
166
|
CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
|
|
166
167
|
`);
|
|
167
168
|
console.log('✅ "api_tokens" table ensured to exist.');
|
|
169
|
+
// The MCP server's audit log: every tool call (see mcp/audit). Main db only.
|
|
170
|
+
await pool.query(`
|
|
171
|
+
CREATE TABLE IF NOT EXISTS streamby.mcp_audit (
|
|
172
|
+
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
|
|
173
|
+
"userId" VARCHAR(255) NOT NULL,
|
|
174
|
+
"tokenId" VARCHAR(255) NOT NULL,
|
|
175
|
+
"projectId" VARCHAR(255),
|
|
176
|
+
tool VARCHAR(100) NOT NULL,
|
|
177
|
+
ok BOOLEAN NOT NULL,
|
|
178
|
+
status INTEGER,
|
|
179
|
+
error TEXT,
|
|
180
|
+
"durationMs" INTEGER NOT NULL,
|
|
181
|
+
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS mcp_audit_token_idx ON streamby.mcp_audit ("tokenId", "createdAt");
|
|
184
|
+
`);
|
|
185
|
+
console.log('✅ "mcp_audit" table ensured to exist.');
|
|
168
186
|
}
|
|
169
187
|
catch (error) {
|
|
170
188
|
console.error('❌ Error ensuring tables exist:', error);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StreamByConfig } from '../types';
|
|
2
|
+
export interface McpAuditEntry {
|
|
3
|
+
userId: string;
|
|
4
|
+
tokenId: string;
|
|
5
|
+
projectId: string | null;
|
|
6
|
+
tool: string;
|
|
7
|
+
ok: boolean;
|
|
8
|
+
status: number | null;
|
|
9
|
+
error: string | null;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function recordMcpCall(entry: McpAuditEntry, config: StreamByConfig): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.recordMcpCall = recordMcpCall;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const manager_1 = require("../models/manager");
|
|
9
|
+
async function recordMcpCall(entry, config) {
|
|
10
|
+
try {
|
|
11
|
+
const dbType = config.databases?.find(db => db.main)?.type ?? 'nosql';
|
|
12
|
+
await (0, manager_1.getModel)('mcp_audit').create({ id: crypto_1.default.randomUUID(), ...entry, createdAt: new Date(), dbType });
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.error('❌ StreamBy MCP: failed to record a tool call:', err instanceof Error ? err.message : err);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RateLimit {
|
|
2
|
+
requests: number;
|
|
3
|
+
windowMs: number;
|
|
4
|
+
}
|
|
5
|
+
export declare const DEFAULT_MCP_RATE_LIMIT: RateLimit;
|
|
6
|
+
export declare function createRateLimiter({ requests, windowMs }: RateLimit): {
|
|
7
|
+
/** Counts a request by `key`: whether it's allowed, and if not, how long until it would be. */
|
|
8
|
+
take(key: string, now?: number): {
|
|
9
|
+
allowed: true;
|
|
10
|
+
} | {
|
|
11
|
+
allowed: false;
|
|
12
|
+
retryAfterMs: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_MCP_RATE_LIMIT = void 0;
|
|
4
|
+
exports.createRateLimiter = createRateLimiter;
|
|
5
|
+
exports.DEFAULT_MCP_RATE_LIMIT = { requests: 120, windowMs: 60000 };
|
|
6
|
+
function createRateLimiter({ requests, windowMs }) {
|
|
7
|
+
const seen = new Map();
|
|
8
|
+
return {
|
|
9
|
+
/** Counts a request by `key`: whether it's allowed, and if not, how long until it would be. */
|
|
10
|
+
take(key, now = Date.now()) {
|
|
11
|
+
const recent = (seen.get(key) ?? []).filter(at => at > now - windowMs);
|
|
12
|
+
if (recent.length >= requests) {
|
|
13
|
+
seen.set(key, recent);
|
|
14
|
+
return { allowed: false, retryAfterMs: recent[0] + windowMs - now };
|
|
15
|
+
}
|
|
16
|
+
recent.push(now);
|
|
17
|
+
seen.set(key, recent);
|
|
18
|
+
return { allowed: true };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Auth, StreamByConfig } from '../types';
|
|
3
|
+
import { ToolCall } from './tools';
|
|
4
|
+
export declare const MCP_SERVER_NAME = "streamby";
|
|
5
|
+
export type ApiCaller = (call: ToolCall) => Promise<{
|
|
6
|
+
status: number;
|
|
7
|
+
body: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
10
|
+
* An MCP server for one request of `auth` (an API token's owner): its tools, the writing ones
|
|
11
|
+
* only for a token with write access. Each tool call goes to the REST API through `api`, and is
|
|
12
|
+
* recorded in the audit log.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createMcpServer(auth: Auth, api: ApiCaller, config: StreamByConfig, version: string): McpServer;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MCP_SERVER_NAME = void 0;
|
|
4
|
+
exports.createMcpServer = createMcpServer;
|
|
5
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
|
+
const tools_1 = require("./tools");
|
|
7
|
+
const audit_1 = require("./audit");
|
|
8
|
+
exports.MCP_SERVER_NAME = 'streamby';
|
|
9
|
+
const errorText = (body, status) => {
|
|
10
|
+
const message = body?.message ?? body?.error;
|
|
11
|
+
return typeof message === 'string' ? message : `StreamBy answered ${status}`;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* An MCP server for one request of `auth` (an API token's owner): its tools, the writing ones
|
|
15
|
+
* only for a token with write access. Each tool call goes to the REST API through `api`, and is
|
|
16
|
+
* recorded in the audit log.
|
|
17
|
+
*/
|
|
18
|
+
function createMcpServer(auth, api, config, version) {
|
|
19
|
+
const server = new mcp_js_1.McpServer({ name: exports.MCP_SERVER_NAME, version });
|
|
20
|
+
const canWrite = auth.apiToken?.access === 'write';
|
|
21
|
+
for (const tool of tools_1.TOOLS) {
|
|
22
|
+
if (tool.write && !canWrite)
|
|
23
|
+
continue;
|
|
24
|
+
server.registerTool(tool.name, {
|
|
25
|
+
title: tool.title,
|
|
26
|
+
description: tool.description,
|
|
27
|
+
inputSchema: tool.input,
|
|
28
|
+
annotations: { readOnlyHint: !tool.write, destructiveHint: false, openWorldHint: false },
|
|
29
|
+
}, async (args) => {
|
|
30
|
+
const started = Date.now();
|
|
31
|
+
const projectId = tool.projectOf?.(args) ?? null;
|
|
32
|
+
let status = null;
|
|
33
|
+
try {
|
|
34
|
+
const answer = await api(tool.call(args));
|
|
35
|
+
status = answer.status;
|
|
36
|
+
const ok = status >= 200 && status < 300;
|
|
37
|
+
await (0, audit_1.recordMcpCall)({
|
|
38
|
+
userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
|
|
39
|
+
ok, status, error: ok ? null : errorText(answer.body, status), durationMs: Date.now() - started,
|
|
40
|
+
}, config);
|
|
41
|
+
if (!ok)
|
|
42
|
+
return { isError: true, content: [{ type: 'text', text: errorText(answer.body, status) }] };
|
|
43
|
+
const result = tool.pick ? tool.pick(answer.body, args) : answer.body;
|
|
44
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }] };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
await (0, audit_1.recordMcpCall)({
|
|
49
|
+
userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
|
|
50
|
+
ok: false, status, error: message, durationMs: Date.now() - started,
|
|
51
|
+
}, config);
|
|
52
|
+
return { isError: true, content: [{ type: 'text', text: `The call failed: ${message}` }] };
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return server;
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export interface ToolCall {
|
|
3
|
+
method: 'GET' | 'POST' | 'PATCH' | 'PUT';
|
|
4
|
+
path: string;
|
|
5
|
+
body?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface ToolDefinition<S extends z.ZodRawShape = z.ZodRawShape> {
|
|
8
|
+
name: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
write: boolean;
|
|
12
|
+
input: S;
|
|
13
|
+
projectOf?: (args: z.infer<z.ZodObject<S>>) => string | undefined;
|
|
14
|
+
call: (args: z.infer<z.ZodObject<S>>) => ToolCall;
|
|
15
|
+
pick?: (data: any, args: z.infer<z.ZodObject<S>>) => unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare const TOOLS: ToolDefinition<any>[];
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TOOLS = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const tool = (definition) => definition;
|
|
6
|
+
const id = (what) => zod_1.z.string().min(1).describe(`The ${what}'s id`);
|
|
7
|
+
const projectId = id('project');
|
|
8
|
+
const enc = encodeURIComponent;
|
|
9
|
+
const byProject = ({ projectId }) => projectId;
|
|
10
|
+
const CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
|
|
11
|
+
const shotFields = {
|
|
12
|
+
description: zod_1.z.string().optional(),
|
|
13
|
+
status: zod_1.z.string().optional().describe('Its status, as the project uses them'),
|
|
14
|
+
assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
|
|
15
|
+
assetId: zod_1.z.string().optional(),
|
|
16
|
+
exportId: zod_1.z.string().optional(),
|
|
17
|
+
dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
18
|
+
order: zod_1.z.number().optional(),
|
|
19
|
+
};
|
|
20
|
+
const taskFields = {
|
|
21
|
+
status: zod_1.z.string().optional(),
|
|
22
|
+
priority: zod_1.z.string().optional(),
|
|
23
|
+
assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
|
|
24
|
+
dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
25
|
+
notes: zod_1.z.string().optional(),
|
|
26
|
+
};
|
|
27
|
+
const rightsFields = {
|
|
28
|
+
rightsHolder: zod_1.z.string().optional(),
|
|
29
|
+
licenseType: zod_1.z.string().optional().describe('e.g. CC0, CC BY 4.0, proprietary'),
|
|
30
|
+
licenseUrl: zod_1.z.string().optional(),
|
|
31
|
+
territory: zod_1.z.string().optional(),
|
|
32
|
+
expiresAt: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
33
|
+
usageRestrictions: zod_1.z.string().optional(),
|
|
34
|
+
notes: zod_1.z.string().optional(),
|
|
35
|
+
};
|
|
36
|
+
// Only the fields given (an unset optional field isn't sent as undefined).
|
|
37
|
+
const defined = (fields) => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
38
|
+
exports.TOOLS = [
|
|
39
|
+
// ── Reading ──
|
|
40
|
+
tool({
|
|
41
|
+
name: 'list_projects', title: 'List projects', write: false,
|
|
42
|
+
description: 'The StreamBy projects this token reaches.',
|
|
43
|
+
input: {},
|
|
44
|
+
call: () => ({ method: 'GET', path: '/projects' }),
|
|
45
|
+
pick: (data) => data.projects,
|
|
46
|
+
}),
|
|
47
|
+
tool({
|
|
48
|
+
name: 'get_project', title: 'Get a project', write: false,
|
|
49
|
+
description: "A project's details: its name, description, members, exports and connections.",
|
|
50
|
+
input: { projectId },
|
|
51
|
+
projectOf: byProject,
|
|
52
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}` }),
|
|
53
|
+
}),
|
|
54
|
+
tool({
|
|
55
|
+
name: 'list_assets', title: 'List assets', write: false,
|
|
56
|
+
description: "A project's assets of a category (images, audios, videos, 3d-models), optionally in one storage connection, and optionally only those whose name contains `query`.",
|
|
57
|
+
input: {
|
|
58
|
+
projectId,
|
|
59
|
+
category: zod_1.z.enum(CATEGORIES),
|
|
60
|
+
connectionId: zod_1.z.string().optional().describe("A storage connection's id; the project's built-in storage if not given"),
|
|
61
|
+
query: zod_1.z.string().optional().describe('Only assets whose name contains it (case-insensitive)'),
|
|
62
|
+
},
|
|
63
|
+
projectOf: byProject,
|
|
64
|
+
call: ({ projectId, category, connectionId }) => ({
|
|
65
|
+
method: 'GET',
|
|
66
|
+
path: connectionId
|
|
67
|
+
? `/projects/${enc(projectId)}/connections/storage/${enc(connectionId)}/files/${category}`
|
|
68
|
+
: `/projects/${enc(projectId)}/storage/${category}`,
|
|
69
|
+
}),
|
|
70
|
+
pick: (data, { query }) => {
|
|
71
|
+
const files = Array.isArray(data?.data) ? data.data : [];
|
|
72
|
+
if (!query)
|
|
73
|
+
return files;
|
|
74
|
+
const q = query.toLowerCase();
|
|
75
|
+
return files.filter(f => [f.name, f.displayName, f.originalName, f.fileName]
|
|
76
|
+
.some(n => typeof n === 'string' && n.toLowerCase().includes(q)));
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
tool({
|
|
80
|
+
name: 'get_asset_metadata', title: "Get an asset's metadata", write: false,
|
|
81
|
+
description: "An asset's metadata (technical data and custom tags).",
|
|
82
|
+
input: { projectId, fileId: id('asset') },
|
|
83
|
+
projectOf: byProject,
|
|
84
|
+
call: ({ projectId, fileId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata` }),
|
|
85
|
+
}),
|
|
86
|
+
tool({
|
|
87
|
+
name: 'get_asset_rights', title: "Get an asset's rights", write: false,
|
|
88
|
+
description: "An asset's rights: holder, license, territory, expiry and restrictions.",
|
|
89
|
+
input: { projectId, assetId: id('asset') },
|
|
90
|
+
projectOf: byProject,
|
|
91
|
+
call: ({ projectId, assetId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights` }),
|
|
92
|
+
}),
|
|
93
|
+
tool({
|
|
94
|
+
name: 'get_workflow', title: "Get a project's workflow", write: false,
|
|
95
|
+
description: "A project's workflow (its node graph).",
|
|
96
|
+
input: { projectId },
|
|
97
|
+
projectOf: byProject,
|
|
98
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/workflow` }),
|
|
99
|
+
}),
|
|
100
|
+
tool({
|
|
101
|
+
name: 'list_pipelines', title: 'List pipelines', write: false,
|
|
102
|
+
description: "A project's pipelines.",
|
|
103
|
+
input: { projectId },
|
|
104
|
+
projectOf: byProject,
|
|
105
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/pipelines` }),
|
|
106
|
+
}),
|
|
107
|
+
tool({
|
|
108
|
+
name: 'get_pipeline', title: 'Get a pipeline', write: false,
|
|
109
|
+
description: 'A pipeline, with its node schema.',
|
|
110
|
+
input: { projectId, pipelineId: id('pipeline') },
|
|
111
|
+
projectOf: byProject,
|
|
112
|
+
call: ({ projectId, pipelineId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}` }),
|
|
113
|
+
}),
|
|
114
|
+
tool({
|
|
115
|
+
name: 'list_exports', title: 'List exports', write: false,
|
|
116
|
+
description: "A project's exports (its published endpoints).",
|
|
117
|
+
input: { projectId },
|
|
118
|
+
projectOf: byProject,
|
|
119
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}` }),
|
|
120
|
+
pick: (data) => data?.project?.exports ?? data?.exports ?? [],
|
|
121
|
+
}),
|
|
122
|
+
tool({
|
|
123
|
+
name: 'get_export', title: 'Get an export', write: false,
|
|
124
|
+
description: 'An export, with its node schema.',
|
|
125
|
+
input: { projectId, exportId: id('export') },
|
|
126
|
+
projectOf: byProject,
|
|
127
|
+
call: ({ projectId, exportId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/exports/${enc(exportId)}` }),
|
|
128
|
+
}),
|
|
129
|
+
tool({
|
|
130
|
+
name: 'get_job', title: 'Get a job', write: false,
|
|
131
|
+
description: "A job's status and progress (transcoding, rendering, ingesting...).",
|
|
132
|
+
input: { jobId: id('job') },
|
|
133
|
+
call: ({ jobId }) => ({ method: 'GET', path: `/jobs/${enc(jobId)}` }),
|
|
134
|
+
}),
|
|
135
|
+
tool({
|
|
136
|
+
name: 'list_sequences', title: 'List production sequences', write: false,
|
|
137
|
+
description: "A project's production sequences.",
|
|
138
|
+
input: { projectId },
|
|
139
|
+
projectOf: byProject,
|
|
140
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/production/sequences` }),
|
|
141
|
+
}),
|
|
142
|
+
tool({
|
|
143
|
+
name: 'list_shots', title: 'List shots', write: false,
|
|
144
|
+
description: "A production sequence's shots.",
|
|
145
|
+
input: { projectId, sequenceId: id('sequence') },
|
|
146
|
+
projectOf: byProject,
|
|
147
|
+
call: ({ projectId, sequenceId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots` }),
|
|
148
|
+
}),
|
|
149
|
+
tool({
|
|
150
|
+
name: 'list_tasks', title: 'List shot tasks', write: false,
|
|
151
|
+
description: "A shot's tasks.",
|
|
152
|
+
input: { projectId, shotId: id('shot') },
|
|
153
|
+
projectOf: byProject,
|
|
154
|
+
call: ({ projectId, shotId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks` }),
|
|
155
|
+
}),
|
|
156
|
+
// ── Writing ──
|
|
157
|
+
tool({
|
|
158
|
+
name: 'update_asset_metadata', title: "Update an asset's custom tags", write: true,
|
|
159
|
+
description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had.",
|
|
160
|
+
input: { projectId, fileId: id('asset'), customTags: zod_1.z.record(zod_1.z.string(), zod_1.z.string()) },
|
|
161
|
+
projectOf: byProject,
|
|
162
|
+
call: ({ projectId, fileId, customTags }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata`, body: { customTags } }),
|
|
163
|
+
}),
|
|
164
|
+
tool({
|
|
165
|
+
name: 'set_asset_rights', title: "Set an asset's rights", write: true,
|
|
166
|
+
description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes.",
|
|
167
|
+
input: { projectId, assetId: id('asset'), ...rightsFields },
|
|
168
|
+
projectOf: byProject,
|
|
169
|
+
call: ({ projectId, assetId, ...rights }) => ({ method: 'PUT', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights`, body: defined(rights) }),
|
|
170
|
+
}),
|
|
171
|
+
tool({
|
|
172
|
+
name: 'create_pipeline', title: 'Create a pipeline', write: true,
|
|
173
|
+
description: 'Creates a pipeline in a project.',
|
|
174
|
+
input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
175
|
+
projectOf: byProject,
|
|
176
|
+
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/pipelines`, body: defined(fields) }),
|
|
177
|
+
}),
|
|
178
|
+
tool({
|
|
179
|
+
name: 'update_pipeline', title: 'Update a pipeline', write: true,
|
|
180
|
+
description: "Changes a pipeline's name, description or order.",
|
|
181
|
+
input: { projectId, pipelineId: id('pipeline'), name: zod_1.z.string().min(1).optional(), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
182
|
+
projectOf: byProject,
|
|
183
|
+
call: ({ projectId, pipelineId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}`, body: defined(fields) }),
|
|
184
|
+
}),
|
|
185
|
+
tool({
|
|
186
|
+
name: 'create_sequence', title: 'Create a production sequence', write: true,
|
|
187
|
+
description: 'Creates a production sequence in a project.',
|
|
188
|
+
input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
189
|
+
projectOf: byProject,
|
|
190
|
+
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences`, body: defined(fields) }),
|
|
191
|
+
}),
|
|
192
|
+
tool({
|
|
193
|
+
name: 'update_sequence', title: 'Update a production sequence', write: true,
|
|
194
|
+
description: "Changes a sequence's name, description or order.",
|
|
195
|
+
input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1).optional(), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
196
|
+
projectOf: byProject,
|
|
197
|
+
call: ({ projectId, sequenceId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}`, body: defined(fields) }),
|
|
198
|
+
}),
|
|
199
|
+
tool({
|
|
200
|
+
name: 'create_shot', title: 'Create a shot', write: true,
|
|
201
|
+
description: 'Creates a shot in a production sequence.',
|
|
202
|
+
input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1), ...shotFields },
|
|
203
|
+
projectOf: byProject,
|
|
204
|
+
call: ({ projectId, sequenceId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots`, body: defined(fields) }),
|
|
205
|
+
}),
|
|
206
|
+
tool({
|
|
207
|
+
name: 'update_shot', title: 'Update a shot', write: true,
|
|
208
|
+
description: 'Changes a shot (its name, status, assignee, due date...).',
|
|
209
|
+
input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1).optional(), ...shotFields },
|
|
210
|
+
projectOf: byProject,
|
|
211
|
+
call: ({ projectId, shotId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}`, body: defined(fields) }),
|
|
212
|
+
}),
|
|
213
|
+
tool({
|
|
214
|
+
name: 'create_task', title: 'Create a shot task', write: true,
|
|
215
|
+
description: 'Creates a task in a shot.',
|
|
216
|
+
input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1), ...taskFields },
|
|
217
|
+
projectOf: byProject,
|
|
218
|
+
call: ({ projectId, shotId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks`, body: defined(fields) }),
|
|
219
|
+
}),
|
|
220
|
+
tool({
|
|
221
|
+
name: 'update_task', title: 'Update a shot task', write: true,
|
|
222
|
+
description: 'Changes a task (its name, status, priority, assignee, due date, notes).',
|
|
223
|
+
input: { projectId, shotId: id('shot'), taskId: id('task'), name: zod_1.z.string().min(1).optional(), ...taskFields },
|
|
224
|
+
projectOf: byProject,
|
|
225
|
+
call: ({ projectId, shotId, taskId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks/${enc(taskId)}`, body: defined(fields) }),
|
|
226
|
+
}),
|
|
227
|
+
];
|
|
@@ -13,6 +13,7 @@ const reencryptCredentials_1 = require("../services/reencryptCredentials");
|
|
|
13
13
|
const wsHub_1 = require("../services/wsHub");
|
|
14
14
|
const auth_1 = require("../services/auth");
|
|
15
15
|
const apiToken_1 = require("./routes/apiToken");
|
|
16
|
+
const mcp_1 = require("./routes/mcp");
|
|
16
17
|
const encryption_1 = require("../utils/encryption");
|
|
17
18
|
const auth_2 = require("./routes/auth");
|
|
18
19
|
const user_1 = require("./routes/user");
|
|
@@ -97,6 +98,7 @@ function createStreamByRouter(config) {
|
|
|
97
98
|
(0, manager_1.registerModel)('user_subscriptions', [mainDb.id], 'user_subscriptions', 'streamby');
|
|
98
99
|
(0, manager_1.registerModel)('user_integrations', [mainDb.id], 'user_integrations', streambySchema);
|
|
99
100
|
(0, manager_1.registerModel)('api_tokens', [mainDb.id], 'api_tokens', streambySchema);
|
|
101
|
+
(0, manager_1.registerModel)('mcp_audit', [mainDb.id], 'mcp_audit', streambySchema);
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
if (config.websocket?.server) {
|
|
@@ -110,6 +112,7 @@ function createStreamByRouter(config) {
|
|
|
110
112
|
router.use(subscription_1.ensureSubscription);
|
|
111
113
|
router.use((0, user_1.userRouter)(config));
|
|
112
114
|
router.use((0, apiToken_1.apiTokenRouter)(config));
|
|
115
|
+
router.use((0, mcp_1.mcpRouter)(config));
|
|
113
116
|
router.use((0, export_1.exportRouter)(config));
|
|
114
117
|
router.use((0, member_1.memberRouter)(config));
|
|
115
118
|
router.use((0, storage_2.storageRouter)(config));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mcpRouter = mcpRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
6
|
+
const server_1 = require("../../mcp/server");
|
|
7
|
+
const rateLimit_1 = require("../../mcp/rateLimit");
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
9
|
+
const { version } = require('../../../package.json');
|
|
10
|
+
const jsonRpcError = (res, status, message) => res.status(status).json({ jsonrpc: '2.0', error: { code: -32000, message }, id: null });
|
|
11
|
+
// StreamBy's MCP server (Streamable HTTP, stateless: one server per request), for agents
|
|
12
|
+
// working with StreamBy through an API token. Each tool calls the REST API right here -- this
|
|
13
|
+
// same process, under the same mount -- with the request's own Authorization header, so it
|
|
14
|
+
// reaches what the token and its owner can and nothing more.
|
|
15
|
+
function mcpRouter(config) {
|
|
16
|
+
const router = (0, express_1.Router)();
|
|
17
|
+
const limiter = (0, rateLimit_1.createRateLimiter)(config.mcp?.rateLimit ?? rateLimit_1.DEFAULT_MCP_RATE_LIMIT);
|
|
18
|
+
router.post('/mcp', async (req, res) => {
|
|
19
|
+
const auth = req.auth;
|
|
20
|
+
if (!auth.apiToken)
|
|
21
|
+
return jsonRpcError(res, 401, 'The MCP server takes an API token (Authorization: Bearer sbt_...)');
|
|
22
|
+
const taken = limiter.take(auth.apiToken.tokenId);
|
|
23
|
+
if (!taken.allowed) {
|
|
24
|
+
res.setHeader('Retry-After', String(Math.ceil(taken.retryAfterMs / 1000)));
|
|
25
|
+
return jsonRpcError(res, 429, 'Too many requests for this API token; try again later');
|
|
26
|
+
}
|
|
27
|
+
const base = config.mcp?.apiBaseUrl ?? `http://127.0.0.1:${req.socket.localPort}${req.baseUrl}`;
|
|
28
|
+
const api = async ({ method, path, body }) => {
|
|
29
|
+
const answer = await fetch(`${base}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { Authorization: req.headers.authorization ?? '', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
32
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
33
|
+
redirect: 'error',
|
|
34
|
+
});
|
|
35
|
+
const text = await answer.text();
|
|
36
|
+
let parsed = null;
|
|
37
|
+
try {
|
|
38
|
+
parsed = text ? JSON.parse(text) : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
parsed = text;
|
|
42
|
+
}
|
|
43
|
+
return { status: answer.status, body: parsed };
|
|
44
|
+
};
|
|
45
|
+
const server = (0, server_1.createMcpServer)(auth, api, config, version);
|
|
46
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
47
|
+
res.on('close', () => { transport.close(); server.close(); });
|
|
48
|
+
try {
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
await transport.handleRequest(req, res, req.body);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
console.error('❌ StreamBy MCP: request failed:', err instanceof Error ? err.message : err);
|
|
54
|
+
if (!res.headersSent)
|
|
55
|
+
jsonRpcError(res, 500, 'Internal error');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
// Stateless: no session to stream to, nor to end.
|
|
59
|
+
router.get('/mcp', (_req, res) => jsonRpcError(res, 405, 'Method not allowed'));
|
|
60
|
+
router.delete('/mcp', (_req, res) => jsonRpcError(res, 405, 'Method not allowed'));
|
|
61
|
+
return router;
|
|
62
|
+
}
|
package/dist/services/auth.js
CHANGED
|
@@ -33,6 +33,9 @@ const enforceApiTokenScope = (req, res, next) => {
|
|
|
33
33
|
const scope = req.auth?.apiToken;
|
|
34
34
|
if (!scope)
|
|
35
35
|
return next();
|
|
36
|
+
// The MCP server handles a token itself: its tools call the routes here, each one checked.
|
|
37
|
+
if (req.path === '/mcp')
|
|
38
|
+
return next();
|
|
36
39
|
if (/^\/user\/api-tokens(\/|$)/.test(req.path)) {
|
|
37
40
|
return res.status(403).json({ error: 'API tokens are managed with a session, not with a token' });
|
|
38
41
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -133,6 +133,13 @@ export interface StreamByConfig {
|
|
|
133
133
|
adapter?: StorageAdapter;
|
|
134
134
|
encrypt?: string;
|
|
135
135
|
encryptPreviousKeys?: string[];
|
|
136
|
+
mcp?: {
|
|
137
|
+
rateLimit?: {
|
|
138
|
+
requests: number;
|
|
139
|
+
windowMs: number;
|
|
140
|
+
};
|
|
141
|
+
apiBaseUrl?: string;
|
|
142
|
+
};
|
|
136
143
|
websocket?: {
|
|
137
144
|
server: WebSocketServer;
|
|
138
145
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamby-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "StreamBy middleware framework for media storage management",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@aws-sdk/client-s3": "^3.528.0",
|
|
34
34
|
"@aws-sdk/s3-request-presigner": "^3.787.0",
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
35
36
|
"@prisma/client": "^6.12.0",
|
|
36
37
|
"bcrypt": "^5.1.1",
|
|
37
38
|
"dotenv": "^16.5.0",
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
"node-fetch": "^3.0.0",
|
|
42
43
|
"pg": "^8.16.3",
|
|
43
44
|
"prisma": "^6.12.0",
|
|
44
|
-
"ws": "^8.20.0"
|
|
45
|
+
"ws": "^8.20.0",
|
|
46
|
+
"zod": "^4.6.7"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
47
49
|
"@types/bcrypt": "^5.0.2",
|