streamby-core 0.34.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,68 @@
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
+
33
+ ## 0.35.0
34
+
35
+ ### Added
36
+
37
+ - **API tokens**, so agents and services can work with StreamBy as a user without the user's
38
+ browser session. The README has the details.
39
+ - `GET`, `POST` and `DELETE /user/api-tokens` let a user list, make and revoke their own
40
+ tokens. These routes need a session: a token can't manage tokens.
41
+ - A token is `sbt_` followed by 32 random bytes. It's shown once, when it's made.
42
+ - **Scope:** each token is `read` (only reads) or `write` (reads and writes). It reaches a
43
+ list of projects, or `'*'` for every project its owner is a member of, including ones they
44
+ join later. It expires in 7, 30, 90 or 365 days, or never.
45
+ - A request made with a token reaches only its scope:
46
+ - a `read` token can't write;
47
+ - a project outside its scope answers 404, and the project list and jobs show only its
48
+ projects;
49
+ - outside a project, it can only make a few reads.
50
+
51
+ The owner's role in each project still applies on top, so a token never does more than
52
+ its owner can, and it stops reaching a project once its owner leaves it.
53
+ - **`resolveApiToken`** (exported, with `API_TOKEN_PREFIX`) is for the host's
54
+ `authProvider`. It turns `Authorization: Bearer sbt_...` into the token's owner and scope,
55
+ or `null` once the token is revoked or expired. The `authProvider` returns the scope as
56
+ `Auth.apiToken`. Without it, a token would have all of its owner's access.
57
+ - **New table: `api_tokens`.** It's created at startup in the main database, and it keeps
58
+ only each token's SHA-256 hash, never the token itself.
59
+
60
+ ### Security
61
+
62
+ - A connection authenticated with an API token can't open the WebSocket. It's closed with
63
+ code 1008. The WebSocket belongs to the browser session and receives every event of its
64
+ user, from any project.
65
+
3
66
  ## 0.34.0
4
67
 
5
68
  ### ⚠️ Migration and deploy
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @streamby/core
1
+ # streamby-core
2
2
 
3
3
  [![License: AGPL v3](https://img.shields.io/badge/License-AGPLv3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
4
4
 
@@ -9,7 +9,7 @@ Part of the [StreamBy](https://streamby.nhexa.cl) ecosystem by TerminalCore Labs
9
9
 
10
10
  ## 🚀 What is it?
11
11
 
12
- `@streamby/core` is a plug-and-play middleware for Express (or compatible frameworks) that enables file uploads, listings and **multi-project access** over services like:
12
+ `streamby-core` is a plug-and-play middleware for Express (or compatible frameworks) that enables file uploads, listings and **multi-project access** over services like:
13
13
 
14
14
  - ✅ AWS S3
15
15
  - ✅ Google Cloud Storage (soon)
@@ -23,7 +23,7 @@ It's designed to be installed inside your existing API as a library — **no nee
23
23
  ## 📦 Installation
24
24
 
25
25
  ```bash
26
- npm install @streamby/core ws
26
+ npm install streamby-core ws
27
27
  npm install -D @types/ws
28
28
  ```
29
29
 
@@ -35,7 +35,7 @@ npm install -D @types/ws
35
35
  import http from 'http';
36
36
  import express from 'express';
37
37
  import { WebSocketServer } from 'ws';
38
- import { createStreamByRouter } from '@streamby/core';
38
+ import { createStreamByRouter } from 'streamby-core';
39
39
 
40
40
  const app = express();
41
41
  const server = http.createServer(app);
@@ -100,6 +100,148 @@ createStreamByRouter({
100
100
 
101
101
  ---
102
102
 
103
+ ## 🎫 API tokens
104
+
105
+ Agents and services work with StreamBy as a user through API tokens, without the user's
106
+ browser session. A user manages their own tokens (`GET`, `POST` and
107
+ `DELETE /streamby/user/api-tokens`, only with a session). Each token has:
108
+
109
+ - **an access level**: `read` (only reads) or `write` (reads and writes);
110
+ - **the projects it reaches**: a list of project ids, or `'*'` for every project the user is a
111
+ member of, including ones they join later;
112
+ - **an expiry**: 7, 30, 90 or 365 days, or none.
113
+
114
+ A token is `sbt_` followed by 32 random bytes. It's shown once, when it's made. Only its
115
+ SHA-256 hash is stored, with a short prefix to tell it apart.
116
+
117
+ Requests send it as `Authorization: Bearer sbt_...`. Your `authProvider` resolves it with
118
+ `resolveApiToken` and returns its owner, with the token's scope in `apiToken`:
119
+
120
+ ```ts
121
+ import { createStreamByRouter, resolveApiToken, API_TOKEN_PREFIX } from 'streamby-core';
122
+
123
+ createStreamByRouter({
124
+ // ...
125
+ authProvider: async (req) => {
126
+ const bearer = req.headers.authorization?.replace(/^Bearer /, '');
127
+ if (bearer?.startsWith(API_TOKEN_PREFIX)) {
128
+ const resolved = await resolveApiToken(bearer); // null once revoked or expired
129
+ if (!resolved) throw new Error('Invalid API token');
130
+ const user = await findUser(resolved.userId); // your own user lookup
131
+ return { userId: user.id, username: user.username, role: user.role, apiToken: resolved.scope };
132
+ }
133
+ // ...your session-based authentication
134
+ },
135
+ });
136
+ ```
137
+
138
+ A request made with a token reaches only what the token's scope allows:
139
+
140
+ - a `read` token can't write;
141
+ - a project outside the token's projects answers 404, and the project list shows only the
142
+ token's projects;
143
+ - a token can't manage tokens;
144
+ - outside a project, a token can only make a few reads (`/auth`, `/projects`, `/jobs/:jobId`,
145
+ `/user/subscription`, `/users/search`).
146
+
147
+ The owner's role in each project still applies on top of that. A token never does more than
148
+ its owner can, and it stops reaching a project as soon as its owner leaves it.
149
+
150
+ ---
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
+
103
245
  ## 👥 Project roles
104
246
 
105
247
  What a user can do in a project depends on their role in that project, as an active member
@@ -120,7 +262,7 @@ nor one that starts a job.
120
262
 
121
263
  ## 🗄️ Database Schema Setup
122
264
 
123
- `@streamby/core` requires a pre-configured database schema for its internal operations. It does not manage database connections directly; instead, it expects the host application to provide initialized connection instances. This ensures full control and flexibility for the host environment.
265
+ `streamby-core` requires a pre-configured database schema for its internal operations. It does not manage database connections directly; instead, it expects the host application to provide initialized connection instances. This ensures full control and flexibility for the host environment.
124
266
 
125
267
  The library will only interact with the specified schema and will not touch other schemas in your database. The `reset` option is opt-in and will only drop and recreate the specified `streamby` schema if explicitly set to `true`.
126
268
 
@@ -128,7 +270,7 @@ The library will only interact with the specified schema and will not touch othe
128
270
 
129
271
  ```ts
130
272
  import { Pool } from "pg";
131
- import { setupStreambyPg } from "@streamby/core/pg/setup"; // Adjust path as per your package structure
273
+ import { setupStreambyPg } from "streamby-core/pg/setup"; // Adjust path as per your package structure
132
274
 
133
275
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
134
276
 
@@ -141,7 +283,7 @@ await setupStreambyPg({ pool, schema: "streamby", reset: false });
141
283
 
142
284
  ```ts
143
285
  import { MongoClient } from "mongodb";
144
- import { setupStreambyMongo } from "@streamby/core/mongo/setup"; // Adjust path as per your package structure
286
+ import { setupStreambyMongo } from "streamby-core/mongo/setup"; // Adjust path as per your package structure
145
287
 
146
288
  const client = new MongoClient(process.env.MONGO_URL!);
147
289
 
@@ -154,7 +296,7 @@ await setupStreambyMongo({ client, dbName: "streamby" });
154
296
 
155
297
  ```ts
156
298
  import express from 'express';
157
- import { createStreamByRouter } from '@streamby/core';
299
+ import { createStreamByRouter } from 'streamby-core';
158
300
 
159
301
  const app = express();
160
302
 
@@ -215,7 +357,7 @@ Unit tests use [Vitest](https://vitest.dev) and [Supertest](https://www.npmjs.co
215
357
 
216
358
  ## 🗺️ API Endpoints
217
359
 
218
- Here's a summary of the key API endpoints provided by `@streamby/core`:
360
+ Here's a summary of the key API endpoints provided by `streamby-core`:
219
361
 
220
362
  ### `GET /streamby/projects`
221
363
 
@@ -330,5 +472,5 @@ Copyright (C) 2026 TerminalCore Labs
330
472
 
331
473
  Licensed under the [GNU Affero General Public License v3.0](./LICENSE) (AGPL-3.0-only).
332
474
 
333
- > **Note for consumers:** unlike most installable packages, this one is intentionally AGPL rather than MIT. AGPL's copyleft applies to any project that depends on it -- if you install `@streamby/core`, your own project must also be licensed under AGPL-3.0 (or a compatible copyleft license). Confirm this is acceptable for your use case before adding this dependency.
475
+ > **Note for consumers:** unlike most installable packages, this one is intentionally AGPL rather than MIT. AGPL's copyleft applies to any project that depends on it -- if you install `streamby-core`, your own project must also be licensed under AGPL-3.0 (or a compatible copyleft license). Confirm this is acceptable for your use case before adding this dependency.
334
476
 
@@ -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(`
@@ -147,6 +148,41 @@ const ensureTablesExist = async (pool) => {
147
148
  CREATE INDEX IF NOT EXISTS user_integrations_userid_idx ON streamby.user_integrations ("userId");
148
149
  `);
149
150
  console.log('✅ "user_integrations" table ensured to exist.');
151
+ // API tokens: only their hash is kept (see services/apiTokens). Main db only, like
152
+ // user_integrations above.
153
+ await pool.query(`
154
+ CREATE TABLE IF NOT EXISTS streamby.api_tokens (
155
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
156
+ "userId" VARCHAR(255) NOT NULL,
157
+ name VARCHAR(255) NOT NULL,
158
+ prefix VARCHAR(32) NOT NULL,
159
+ hash CHAR(64) NOT NULL UNIQUE,
160
+ access VARCHAR(10) NOT NULL,
161
+ "projectIds" JSONB NOT NULL,
162
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
163
+ "expiresAt" TIMESTAMP WITH TIME ZONE,
164
+ "lastUsedAt" TIMESTAMP WITH TIME ZONE
165
+ );
166
+ CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
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.');
150
186
  }
151
187
  catch (error) {
152
188
  console.error('❌ Error ensuring tables exist:', error);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { createStreamByRouter } from './middleware/createRouter';
2
2
  export { emitToUser } from './services/wsHub';
3
3
  export { createNotification } from './services/notification';
4
- export type { UserPlan, UserSubscription } from './types';
4
+ export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenAccess } from './types';
5
+ export { resolveApiToken, API_TOKEN_PREFIX } from './services/apiTokens';
6
+ export type { ResolvedApiToken } from './services/apiTokens';
package/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
3
+ exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
4
4
  var createRouter_1 = require("./middleware/createRouter");
5
5
  Object.defineProperty(exports, "createStreamByRouter", { enumerable: true, get: function () { return createRouter_1.createStreamByRouter; } });
6
6
  var wsHub_1 = require("./services/wsHub");
7
7
  Object.defineProperty(exports, "emitToUser", { enumerable: true, get: function () { return wsHub_1.emitToUser; } });
8
8
  var notification_1 = require("./services/notification");
9
9
  Object.defineProperty(exports, "createNotification", { enumerable: true, get: function () { return notification_1.createNotification; } });
10
+ var apiTokens_1 = require("./services/apiTokens");
11
+ Object.defineProperty(exports, "resolveApiToken", { enumerable: true, get: function () { return apiTokens_1.resolveApiToken; } });
12
+ Object.defineProperty(exports, "API_TOKEN_PREFIX", { enumerable: true, get: function () { return apiTokens_1.API_TOKEN_PREFIX; } });
@@ -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
+ ];
@@ -12,6 +12,8 @@ const backfillBuiltinConnections_1 = require("../services/backfillBuiltinConnect
12
12
  const reencryptCredentials_1 = require("../services/reencryptCredentials");
13
13
  const wsHub_1 = require("../services/wsHub");
14
14
  const auth_1 = require("../services/auth");
15
+ const apiToken_1 = require("./routes/apiToken");
16
+ const mcp_1 = require("./routes/mcp");
15
17
  const encryption_1 = require("../utils/encryption");
16
18
  const auth_2 = require("./routes/auth");
17
19
  const user_1 = require("./routes/user");
@@ -95,6 +97,8 @@ function createStreamByRouter(config) {
95
97
  (0, manager_1.registerModel)('users', [mainDb.id], 'users', 'accounts');
96
98
  (0, manager_1.registerModel)('user_subscriptions', [mainDb.id], 'user_subscriptions', 'streamby');
97
99
  (0, manager_1.registerModel)('user_integrations', [mainDb.id], 'user_integrations', streambySchema);
100
+ (0, manager_1.registerModel)('api_tokens', [mainDb.id], 'api_tokens', streambySchema);
101
+ (0, manager_1.registerModel)('mcp_audit', [mainDb.id], 'mcp_audit', streambySchema);
98
102
  }
99
103
  }
100
104
  if (config.websocket?.server) {
@@ -104,8 +108,11 @@ function createStreamByRouter(config) {
104
108
  // but for the public export route (see authenticate).
105
109
  router.use((0, auth_2.authRouter)(config));
106
110
  router.use((0, auth_1.authenticate)(config));
111
+ router.use(auth_1.enforceApiTokenScope);
107
112
  router.use(subscription_1.ensureSubscription);
108
113
  router.use((0, user_1.userRouter)(config));
114
+ router.use((0, apiToken_1.apiTokenRouter)(config));
115
+ router.use((0, mcp_1.mcpRouter)(config));
109
116
  router.use((0, export_1.exportRouter)(config));
110
117
  router.use((0, member_1.memberRouter)(config));
111
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 apiTokenRouter(config: StreamByConfig): Router;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.apiTokenRouter = apiTokenRouter;
4
+ const express_1 = require("express");
5
+ const manager_1 = require("../../models/manager");
6
+ const auth_1 = require("../../utils/auth");
7
+ const apiTokens_1 = require("../../services/apiTokens");
8
+ // The signed-in user's own API tokens (see services/apiTokens): listed, made, revoked. Only
9
+ // with a session: a token can't manage tokens (see enforceApiTokenScope).
10
+ function apiTokenRouter(config) {
11
+ const router = (0, express_1.Router)();
12
+ const Project = (0, manager_1.getModel)('projects');
13
+ router.get('/user/api-tokens', async (req, res) => {
14
+ try {
15
+ const auth = req.auth;
16
+ res.json({ apiTokens: await (0, apiTokens_1.listApiTokens)(auth.userId) });
17
+ }
18
+ catch (err) {
19
+ res.status(500).json({ message: 'Failed to list API tokens', details: err.message });
20
+ }
21
+ });
22
+ // The token itself is in this response only: it's never stored, nor shown again.
23
+ router.post('/user/api-tokens', async (req, res) => {
24
+ try {
25
+ const auth = req.auth;
26
+ const { name, access, projectIds, expiresInDays } = req.body ?? {};
27
+ // A token reaches only projects its owner is in.
28
+ if (Array.isArray(projectIds)) {
29
+ for (const id of projectIds) {
30
+ const project = typeof id === 'string' ? await Project.findOne({ _id: id }) : null;
31
+ if (!(0, auth_1.isProjectMember)(project, auth.userId)) {
32
+ return res.status(400).json({ message: `You are not a member of project '${id}'` });
33
+ }
34
+ }
35
+ }
36
+ const { token, info } = await (0, apiTokens_1.createApiToken)(auth.userId, { name, access, projectIds, expiresInDays: expiresInDays ?? null }, config);
37
+ res.status(201).json({ token, apiToken: info, message: 'Copy the token now: it will not be shown again' });
38
+ }
39
+ catch (err) {
40
+ if (err instanceof apiTokens_1.ApiTokenError)
41
+ return res.status(err.status).json({ message: err.message });
42
+ res.status(500).json({ message: 'Failed to create API token', details: err.message });
43
+ }
44
+ });
45
+ router.delete('/user/api-tokens/:tokenId', async (req, res) => {
46
+ try {
47
+ const auth = req.auth;
48
+ const revoked = await (0, apiTokens_1.revokeApiToken)(auth.userId, req.params.tokenId);
49
+ if (!revoked)
50
+ return res.status(404).json({ message: 'API token not found' });
51
+ res.status(204).end();
52
+ }
53
+ catch (err) {
54
+ res.status(500).json({ message: 'Failed to revoke API token', details: err.message });
55
+ }
56
+ });
57
+ return router;
58
+ }
@@ -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
+ }
@@ -8,6 +8,7 @@ const jobQueue_1 = require("../../services/jobQueue");
8
8
  const mediaProcessor_1 = require("../../services/mediaProcessor");
9
9
  const storage_1 = require("../../providers/storage");
10
10
  const connectionManager_1 = require("../../adapters/database/connectionManager");
11
+ const auth_2 = require("../../services/auth");
11
12
  function getMetadataCollection() {
12
13
  const ids = (0, connectionManager_1.getConnectedIds)().filter(id => (0, connectionManager_1.getConnection)(id).type === 'nosql');
13
14
  if (!ids.length)
@@ -33,6 +34,9 @@ function mediaRouter(config) {
33
34
  const job = (0, jobQueue_1.getJob)(req.params.jobId);
34
35
  if (!job)
35
36
  return res.status(404).json({ message: 'Job not found' });
37
+ // With an API token, only a job of a project it reaches.
38
+ if (!(0, auth_2.scopeReachesProject)(auth.apiToken, job.projectId))
39
+ return res.status(404).json({ message: 'Job not found' });
36
40
  if (job.userId !== auth.userId) {
37
41
  const project = await Project.findOne({ _id: job.projectId });
38
42
  if (!(0, auth_1.isProjectMember)(project, auth.userId))
@@ -8,6 +8,7 @@ const connectionManager_1 = require("../../adapters/database/connectionManager")
8
8
  const sql_1 = require("../../adapters/database/sql");
9
9
  const sanitize_1 = require("../../utils/sanitize");
10
10
  const projectIntegration_1 = require("../../services/projectIntegration");
11
+ const auth_2 = require("../../services/auth");
11
12
  function projectRouter(config) {
12
13
  const router = (0, express_1.Router)();
13
14
  const Project = (0, manager_1.getModel)('projects');
@@ -32,6 +33,9 @@ function projectRouter(config) {
32
33
  const currentUserMember = project.members?.find((m) => m.userId?.toString() === auth.userId?.toString());
33
34
  if (!currentUserMember || currentUserMember.status !== 'active')
34
35
  return false;
36
+ // With an API token, only the projects it reaches.
37
+ if (!(0, auth_2.scopeReachesProject)(auth.apiToken, (project._id ?? project.id)?.toString()))
38
+ return false;
35
39
  if (filterArchived !== undefined) {
36
40
  return (currentUserMember.archived || false) === filterArchived;
37
41
  }
@@ -0,0 +1,47 @@
1
+ import { ApiTokenAccess, ApiTokenScope, StreamByConfig } from '../types';
2
+ export declare const API_TOKEN_PREFIX = "sbt_";
3
+ export declare const API_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90, 365];
4
+ export interface ApiTokenRecord {
5
+ id: string;
6
+ userId: string;
7
+ name: string;
8
+ prefix: string;
9
+ hash: string;
10
+ access: ApiTokenAccess;
11
+ projectIds: string[];
12
+ createdAt: Date;
13
+ expiresAt: Date | null;
14
+ lastUsedAt: Date | null;
15
+ }
16
+ /** What a token's owner sees of it. */
17
+ export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds'> & {
18
+ projectIds: string[] | '*';
19
+ };
20
+ export declare class ApiTokenError extends Error {
21
+ status: number;
22
+ constructor(message: string, status?: number);
23
+ }
24
+ export interface CreateApiTokenInput {
25
+ name: string;
26
+ access: ApiTokenAccess;
27
+ projectIds: string[] | '*';
28
+ expiresInDays: number | null;
29
+ }
30
+ /** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
31
+ export declare function createApiToken(userId: string, input: CreateApiTokenInput, config: StreamByConfig): Promise<{
32
+ token: string;
33
+ info: ApiTokenInfo;
34
+ }>;
35
+ /** The tokens `userId` has. */
36
+ export declare function listApiTokens(userId: string): Promise<ApiTokenInfo[]>;
37
+ /** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
38
+ export declare function revokeApiToken(userId: string, tokenId: string): Promise<boolean>;
39
+ export interface ResolvedApiToken {
40
+ userId: string;
41
+ scope: ApiTokenScope;
42
+ }
43
+ /**
44
+ * The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
45
+ * known (revoked), or has expired. Marks when it was last used.
46
+ */
47
+ export declare function resolveApiToken(token: unknown): Promise<ResolvedApiToken | null>;
@@ -0,0 +1,110 @@
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.ApiTokenError = exports.API_TOKEN_EXPIRY_DAYS = exports.API_TOKEN_PREFIX = void 0;
7
+ exports.createApiToken = createApiToken;
8
+ exports.listApiTokens = listApiTokens;
9
+ exports.revokeApiToken = revokeApiToken;
10
+ exports.resolveApiToken = resolveApiToken;
11
+ const crypto_1 = __importDefault(require("crypto"));
12
+ const manager_1 = require("../models/manager");
13
+ // API tokens: how an agent or a service works with StreamBy as a user, without their browser
14
+ // session. A token is "sbt_" and 32 random bytes; only its hash is stored, with a prefix to
15
+ // tell it apart in a list. It's sent as `Authorization: Bearer sbt_...` and the host's
16
+ // authProvider resolves it (resolveApiToken), so the request is its owner's -- limited by the
17
+ // token's scope (read or write, which projects), and never more than its owner can do: the
18
+ // owner's role in each project still decides.
19
+ exports.API_TOKEN_PREFIX = 'sbt_';
20
+ const PREFIX_SHOWN = exports.API_TOKEN_PREFIX.length + 8;
21
+ exports.API_TOKEN_EXPIRY_DAYS = [7, 30, 90, 365];
22
+ const ALL_PROJECTS = '*';
23
+ class ApiTokenError extends Error {
24
+ constructor(message, status = 400) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ exports.ApiTokenError = ApiTokenError;
30
+ const hashOf = (token) => crypto_1.default.createHash('sha256').update(token).digest('hex');
31
+ const infoOf = ({ hash: _hash, userId: _userId, ...info }) => ({
32
+ ...info,
33
+ projectIds: parseProjectIds(info.projectIds),
34
+ });
35
+ // As stored (a list, read back from SQL JSON as a list or its text): ['*'] is every project.
36
+ const parseProjectIds = (value) => {
37
+ if (typeof value === 'string') {
38
+ try {
39
+ return parseProjectIds(JSON.parse(value));
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ if (!Array.isArray(value))
46
+ return [];
47
+ const ids = value.map(String);
48
+ return ids.includes(ALL_PROJECTS) ? '*' : ids;
49
+ };
50
+ const mainDbType = (config) => config.databases?.find(db => db.main)?.type ?? 'nosql';
51
+ /** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
52
+ async function createApiToken(userId, input, config) {
53
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
54
+ if (!name)
55
+ throw new ApiTokenError('A token needs a name.');
56
+ if (input.access !== 'read' && input.access !== 'write')
57
+ throw new ApiTokenError("access must be 'read' or 'write'.");
58
+ if (input.projectIds !== '*' && (!Array.isArray(input.projectIds) || !input.projectIds.length || !input.projectIds.every(id => typeof id === 'string' && id && id !== ALL_PROJECTS))) {
59
+ throw new ApiTokenError("projectIds must be '*' or a list of project ids.");
60
+ }
61
+ if (input.expiresInDays !== null && !exports.API_TOKEN_EXPIRY_DAYS.includes(input.expiresInDays)) {
62
+ throw new ApiTokenError(`expiresInDays must be one of ${exports.API_TOKEN_EXPIRY_DAYS.join(', ')}, or null.`);
63
+ }
64
+ const token = exports.API_TOKEN_PREFIX + crypto_1.default.randomBytes(32).toString('base64url');
65
+ const now = new Date();
66
+ const record = {
67
+ id: crypto_1.default.randomUUID(),
68
+ userId,
69
+ name,
70
+ prefix: token.slice(0, PREFIX_SHOWN),
71
+ hash: hashOf(token),
72
+ access: input.access,
73
+ projectIds: input.projectIds === '*' ? [ALL_PROJECTS] : [...new Set(input.projectIds)],
74
+ createdAt: now,
75
+ expiresAt: input.expiresInDays === null ? null : new Date(now.getTime() + input.expiresInDays * 86400000),
76
+ lastUsedAt: null,
77
+ };
78
+ await (0, manager_1.getModel)('api_tokens').create({ ...record, dbType: mainDbType(config) });
79
+ return { token, info: infoOf(record) };
80
+ }
81
+ /** The tokens `userId` has. */
82
+ async function listApiTokens(userId) {
83
+ const records = (await (0, manager_1.getModel)('api_tokens').find({ userId }));
84
+ return records
85
+ .map(infoOf)
86
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
87
+ }
88
+ /** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
89
+ async function revokeApiToken(userId, tokenId) {
90
+ return (await (0, manager_1.getModel)('api_tokens').delete({ id: tokenId, userId })) > 0;
91
+ }
92
+ /**
93
+ * The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
94
+ * known (revoked), or has expired. Marks when it was last used.
95
+ */
96
+ async function resolveApiToken(token) {
97
+ if (typeof token !== 'string' || !token.startsWith(exports.API_TOKEN_PREFIX))
98
+ return null;
99
+ const ApiTokens = (0, manager_1.getModel)('api_tokens');
100
+ const record = (await ApiTokens.findOne({ hash: hashOf(token) }));
101
+ if (!record)
102
+ return null;
103
+ if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now())
104
+ return null;
105
+ ApiTokens.update({ id: record.id }, { lastUsedAt: new Date() }).catch(() => { });
106
+ return {
107
+ userId: record.userId,
108
+ scope: { tokenId: record.id, access: record.access, projectIds: parseProjectIds(record.projectIds) },
109
+ };
110
+ }
@@ -1,4 +1,7 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
- import { StreamByConfig } from '../types';
2
+ import { Auth, StreamByConfig } from '../types';
3
3
  export declare const isPublicRoute: (method: string, path: string) => boolean;
4
+ /** Whether a token's scope reaches a project. */
5
+ export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
6
+ export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
4
7
  export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.authenticate = exports.isPublicRoute = void 0;
3
+ exports.authenticate = exports.enforceApiTokenScope = exports.scopeReachesProject = exports.isPublicRoute = void 0;
4
4
  // The one route open without a session: a project's public export, read
5
5
  // (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
6
6
  // that has an "export" segment somewhere.
@@ -8,6 +8,49 @@ const PUBLIC_EXPORT = /^\/[^/]+\/export\/[^/]+\/?$/;
8
8
  const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
9
9
  const isPublicRoute = (method, path) => READ_METHODS.includes(method.toUpperCase()) && PUBLIC_EXPORT.test(path);
10
10
  exports.isPublicRoute = isPublicRoute;
11
+ // ── API tokens ──
12
+ // A request made with an API token reaches only what the token's scope does: no writes with a
13
+ // read token, no project outside its projects (as if it didn't exist), no managing tokens, and
14
+ // of the routes outside a project only the few below. Its owner's role in each project still
15
+ // decides on top (the routes' own checks).
16
+ // The project a route acts on, by its path.
17
+ const PROJECT_PATHS = [/^\/projects\/([^/]+)/, /^\/(?:upload|delete)-project-image-url\/([^/]+)/];
18
+ const NOT_A_PROJECT = new Set(['create', 'explore']);
19
+ const projectIdOf = (path) => {
20
+ for (const pattern of PROJECT_PATHS) {
21
+ const id = pattern.exec(path)?.[1];
22
+ if (id && !NOT_A_PROJECT.has(id))
23
+ return decodeURIComponent(id);
24
+ }
25
+ return undefined;
26
+ };
27
+ // Outside a project, a token reaches only these reads (each limited to its scope there).
28
+ const OUTSIDE_A_PROJECT = [/^\/auth\/?$/, /^\/projects\/?$/, /^\/jobs\/[^/]+\/?$/, /^\/user\/subscription\/?$/, /^\/users\/search\/?$/];
29
+ /** Whether a token's scope reaches a project. */
30
+ const scopeReachesProject = (scope, projectId) => !scope || scope.projectIds === '*' || scope.projectIds.includes(projectId);
31
+ exports.scopeReachesProject = scopeReachesProject;
32
+ const enforceApiTokenScope = (req, res, next) => {
33
+ const scope = req.auth?.apiToken;
34
+ if (!scope)
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();
39
+ if (/^\/user\/api-tokens(\/|$)/.test(req.path)) {
40
+ return res.status(403).json({ error: 'API tokens are managed with a session, not with a token' });
41
+ }
42
+ if (!READ_METHODS.includes(req.method.toUpperCase()) && scope.access !== 'write') {
43
+ return res.status(403).json({ error: 'This API token can only read' });
44
+ }
45
+ const projectId = projectIdOf(req.path);
46
+ if (projectId) {
47
+ return (0, exports.scopeReachesProject)(scope, projectId) ? next() : res.status(404).json({ message: 'Project not found' });
48
+ }
49
+ if (READ_METHODS.includes(req.method.toUpperCase()) && OUTSIDE_A_PROJECT.some(route => route.test(req.path)))
50
+ return next();
51
+ return res.status(403).json({ error: 'Not available with an API token' });
52
+ };
53
+ exports.enforceApiTokenScope = enforceApiTokenScope;
11
54
  const authenticate = (config) => async (req, res, next) => {
12
55
  if ((0, exports.isPublicRoute)(req.method, req.path)) {
13
56
  return next();
@@ -15,6 +15,12 @@ function initWsHub(wss, config) {
15
15
  ws.close(1008, 'Unauthorized');
16
16
  return;
17
17
  }
18
+ // The WebSocket is the browser session's: it gets every event of its user, whatever the
19
+ // project, so an API token (scoped to some projects, maybe read-only) never opens it.
20
+ if (auth.apiToken) {
21
+ ws.close(1008, "API tokens can't open the WebSocket");
22
+ return;
23
+ }
18
24
  userId = auth.userId;
19
25
  }
20
26
  catch (err) {
@@ -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
  };
@@ -158,6 +165,13 @@ export interface Auth {
158
165
  userId: string;
159
166
  username: string;
160
167
  role: 'viewer' | 'editor' | 'admin';
168
+ apiToken?: ApiTokenScope;
169
+ }
170
+ export type ApiTokenAccess = 'read' | 'write';
171
+ export interface ApiTokenScope {
172
+ tokenId: string;
173
+ access: ApiTokenAccess;
174
+ projectIds: string[] | '*';
161
175
  }
162
176
  export type AuthProvider = (req: Request) => Promise<Auth>;
163
177
  export type UserPlan = 'freemium' | 'subscriber' | 'admin';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.34.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",