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 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
- -- Reverted: a project has no notion of a "default" database/storage connection —
84
- -- each dataSourceNode/ingestNode always declares its own connection explicitly.
85
- ALTER TABLE streamby.projects DROP COLUMN IF EXISTS "defaultDatabaseId";
86
- ALTER TABLE streamby.projects DROP COLUMN IF EXISTS "defaultStorageId";
87
- ALTER TABLE streamby.projects DROP COLUMN IF EXISTS "defaultConnectionsBackfilledAt";
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,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function mcpRouter(config: StreamByConfig): Router;
@@ -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
+ }
@@ -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
  }
@@ -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.35.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",