streamby-core 0.37.0 → 0.38.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.38.0
4
+
5
+ ### Added
6
+
7
+ - **Uploading and downloading files from the MCP server**, so an agent can bring files from and
8
+ to the tools it works with (Blender's or Godot's own MCP, a script). The file never goes
9
+ through the conversation: the tools give signed URLs, and the tool that has the file uploads
10
+ or downloads it directly. See the README for an example with Blender.
11
+ - `request_asset_upload` (`assets:write`): a new asset, or a new version of one, and the URL
12
+ to upload its file to.
13
+ - `get_asset_download_url` (`assets:read`): a URL to download an asset's file, its latest
14
+ version or an earlier one. `list_asset_versions` lists them.
15
+ - **Asset versions are kept, and an asset's URL doesn't change when it's replaced.** A new
16
+ version (`replace-url`, from the UI too) is still uploaded to the asset's own key, so a site
17
+ that links to the asset shows the new one. Before that, the current version is copied to a
18
+ key of its own, `<key>.v<n>.<ext>`, and recorded in `asset_versions` (an asset's first
19
+ version is recorded on upload; an older asset gets it when it's first replaced). If it can't
20
+ be copied, nothing is replaced and no upload URL is given. The `replace-url` routes answer
21
+ the new version's `versionId` and `version`. Deleting an asset deletes every key of it.
22
+ - New `StorageAdapter.copyFile`, implemented by the S3 adapter. With an adapter without it,
23
+ asking for a new version answers 501.
24
+ - StreamBy doesn't set `Cache-Control` on uploads, so a browser or a CDN may serve a replaced
25
+ asset's earlier version for a while: set it where the files are served (see the README).
26
+ - `GET /projects/:projectId/assets/:fileId/download-url` (`?versionId=` for an earlier version):
27
+ a signed URL that downloads the file with its name, wherever it's stored.
28
+ - **Deliverables**: an asset, or one of its versions, delivered for a production shot or for an
29
+ export, kept in the NoSQL database's `deliverables` collection. `GET`, `POST` and
30
+ `DELETE /projects/:projectId/deliverables` list (by `shotId` or `exportId`), mark and remove
31
+ them; `list_deliverables`, `mark_deliverable` and `remove_deliverable` in the MCP server.
32
+ Deleting a shot, a sequence, an export or an asset removes its deliverables.
33
+ - **`mcp.signedUrlTtl`**: how long the signed URLs given to an API token work, also when it
34
+ lists files. Default: 15 minutes to upload, 5 to download. A session's are as before.
35
+ - `StorageAdapter.getPresignedUploadUrl` and `getPresignedGetUrl` take how long the URL works
36
+ (and `getPresignedGetUrl` the name to download it with).
37
+
38
+ - `GET /projects/:id` answers `hasProductionData`: whether the project has production
39
+ sequences, so a client can show them even without a production node in its workflow.
40
+
41
+ ### Changed
42
+
43
+ - **Production data is checked before it's saved.** Sequences, shots and tasks need a name
44
+ that isn't blank; a status must be `todo`, `in_progress`, `review` or `done`, and a task's
45
+ priority `low`, `medium` or `high`; a shot's `assignedTo` must list active members of the
46
+ project, and a task's be one of them (or null); a due date must be a date. Otherwise they
47
+ answer 400. A shot is made only in a sequence of the project, and a task only in a shot of
48
+ it (404 otherwise).
49
+ - The MCP tools for shots take `assignedTo` as a list of user ids, as the API does, and their
50
+ statuses and priorities are listed in their schema.
51
+ - **The MCP tools take the path a user does.** `create_sequence`, `create_shot` and
52
+ `create_task` first add a production node (a Shot node) to the project's workflow when it
53
+ has none, as dropping one on the canvas does, and make nothing if it can't be added. So they
54
+ need `workflow:write` as well as `production:write`. Each tool's description says where its
55
+ result shows in the UI.
56
+
57
+ ### Fixed
58
+
59
+ - **Deleting production data leaves nothing behind.** Deleting a sequence deletes its shots'
60
+ tasks too (they were left), and deleting a project deletes its sequences, shots, tasks and
61
+ deliverables. Deleting a sequence that doesn't exist answers 404.
62
+ - **Only editors and admins upload a new version of an asset.** The `replace-url` routes let
63
+ any member of the project, a viewer included, replace an asset's file.
64
+ - The ingest job no longer records a second version of the file an upload already recorded.
65
+
3
66
  ## 0.37.0
4
67
 
5
68
  ### ⚠️ Breaking changes
package/README.md CHANGED
@@ -203,12 +203,72 @@ A token sees only the tools whose permissions it has.
203
203
  | `get_export` | `exports:read` |
204
204
  | `get_job` | `jobs:read` |
205
205
  | `list_sequences`, `list_shots`, `list_tasks` | `production:read` |
206
- | `create_sequence`, `update_sequence`, `create_shot`, `update_shot`, `create_task`, `update_task` | `production:write` |
206
+ | `update_sequence`, `update_shot`, `update_task` | `production:write` |
207
+ | `create_sequence`, `create_shot`, `create_task` | `production:write` and `workflow:write` |
207
208
  | `delete_sequence`, `delete_shot`, `delete_task` | `production:delete` |
209
+ | `list_asset_versions`, `get_asset_download_url`, `list_deliverables` | `assets:read` |
210
+ | `request_asset_upload`, `mark_deliverable`, `remove_deliverable` | `assets:write` |
211
+
212
+ The tools take the path a user does in the UI, and each one's description says where its
213
+ result shows there. Production data lives under a production node of the project's workflow:
214
+ if the workflow has none yet, `create_sequence`, `create_shot` and `create_task` first add a
215
+ Shot node to it, as dropping one on the canvas does (so they need `workflow:write`), and make
216
+ nothing if it can't be added.
208
217
 
209
218
  The tools that delete are marked as destructive (`destructiveHint`), so a client can ask before
210
219
  running them.
211
220
 
221
+ **Files**
222
+
223
+ A file never goes through the conversation. `request_asset_upload` makes a new asset (or a new
224
+ version of one) and gives a signed URL to `PUT` its bytes to; `get_asset_download_url` gives a
225
+ signed URL to `GET` an asset's file, its latest version or any earlier one. The tool that has
226
+ the file uploads or downloads it directly, for example Blender's or Godot's own MCP server,
227
+ which can run code. Each URL is for that one file, and given to an API token it works only
228
+ briefly: 15 minutes to upload, 5 to download (`mcp.signedUrlTtl`).
229
+
230
+ **Versions.** Replacing an asset doesn't change its URL: the new version is uploaded to the
231
+ asset's own key, so a site that links to the asset shows the new one with no deploy. Before
232
+ that, the current version is copied to a key of its own (`<key>.v<n>.<ext>`) and recorded, so
233
+ every earlier version can still be downloaded by its id; if it can't be copied, nothing is
234
+ replaced. A storage adapter keeps versions only if it can copy (`copyFile`; the S3 one does);
235
+ otherwise asking for a new version answers 501. Deleting the asset deletes every key of it.
236
+
237
+ Since the URL stays the same, a browser or a CDN may keep serving the earlier version for a
238
+ while. StreamBy doesn't set `Cache-Control` on uploads (a signed URL that did would require
239
+ every client to send the same header): set it where the files are served, for example a short
240
+ `max-age` or `no-cache` on the CDN in front of the bucket, or invalidate the path when an asset
241
+ is replaced.
242
+
243
+ A **deliverable** is an asset, or one of its versions, delivered for a
244
+ production shot or for an export; a shot or an export can have several, and the shot's panel
245
+ in the UI shows them.
246
+
247
+ For example, with Claude connected to StreamBy and to Blender's MCP server, asking *"export the
248
+ scene as glTF and deliver it for shot SH010 of Portfolio"* goes:
249
+
250
+ 1. `list_projects`, `list_sequences`, `list_shots`: the project and the shot's id.
251
+ 2. `request_asset_upload` with `category: '3d-models'`, `fileName: 'SH010_layout.glb'` and
252
+ `contentType: 'model/gltf-binary'`: the new asset's id, and where to upload it.
253
+ 3. In Blender, code that exports the scene and uploads it:
254
+
255
+ ```python
256
+ import bpy, urllib.request
257
+ path = bpy.app.tempdir + "SH010_layout.glb"
258
+ bpy.ops.export_scene.gltf(filepath=path, export_format="GLB")
259
+ with open(path, "rb") as f:
260
+ req = urllib.request.Request(UPLOAD_URL, data=f.read(), method="PUT",
261
+ headers={"Content-Type": "model/gltf-binary"})
262
+ urllib.request.urlopen(req)
263
+ ```
264
+
265
+ 4. `mark_deliverable` with the asset's id and `shotId`: it shows in the shot's panel.
266
+
267
+ Bringing an asset into Blender or Godot is the other way around: `get_asset_download_url`, and
268
+ code there that downloads the URL and imports the file (in Blender,
269
+ `bpy.ops.import_scene.gltf(filepath=...)`). A new version of the same asset is
270
+ `request_asset_upload` with its `assetId`.
271
+
212
272
  **Limits and audit**
213
273
 
214
274
  - Each token can make at most 120 requests a minute (a sliding window). Past that, StreamBy
@@ -224,6 +284,7 @@ createStreamByRouter({
224
284
  // ...
225
285
  mcp: {
226
286
  rateLimit: { requests: 120, windowMs: 60_000 }, // the default
287
+ signedUrlTtl: { upload: 900, download: 300 }, // the default, in seconds
227
288
  // apiBaseUrl: 'http://127.0.0.1:3000/streamby', // where tools reach the REST API;
228
289
  // by default this same process, at the request's local port and mount path
229
290
  },
@@ -8,8 +8,9 @@ export declare class S3Adapter implements StorageAdapter {
8
8
  deleteProjectDirectory(projectId: string): Promise<any>;
9
9
  getPresignedUrl(contentType: string, projectId: string): Promise<any>;
10
10
  getPresignedProjectImageUrl(projectId: string): Promise<any>;
11
- getPresignedGetUrl(key: string): Promise<string>;
12
- getPresignedUploadUrl(key: string, contentType: string): Promise<string>;
11
+ getPresignedGetUrl(key: string, expiresIn?: number, downloadName?: string): Promise<string>;
12
+ getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
13
+ copyFile(from: string, to: string): Promise<void>;
13
14
  deleteFile(key: string): Promise<void>;
14
15
  listFilesByCategory(projectId: string, category: string): Promise<StorageFileInfo[]>;
15
16
  private inferMimeType;
@@ -64,13 +64,21 @@ class S3Adapter {
64
64
  const publicUrl = `https://${this.bucket}.s3.amazonaws.com/${key}`;
65
65
  return { url, publicUrl };
66
66
  }
67
- async getPresignedGetUrl(key) {
68
- const command = new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: key });
69
- return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn: 86400 });
67
+ async getPresignedGetUrl(key, expiresIn = 86400, downloadName) {
68
+ const command = new client_s3_1.GetObjectCommand({
69
+ Bucket: this.bucket,
70
+ Key: key,
71
+ ...(downloadName ? { ResponseContentDisposition: contentDisposition(downloadName) } : {}),
72
+ });
73
+ return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
70
74
  }
71
- async getPresignedUploadUrl(key, contentType) {
75
+ async getPresignedUploadUrl(key, contentType, expiresIn = 3600) {
72
76
  const command = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType });
73
- return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn: 3600 });
77
+ return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
78
+ }
79
+ async copyFile(from, to) {
80
+ const source = `${this.bucket}/${from.split('/').map(encodeURIComponent).join('/')}`;
81
+ await this.s3.send(new client_s3_1.CopyObjectCommand({ Bucket: this.bucket, CopySource: source, Key: to }));
74
82
  }
75
83
  async deleteFile(key) {
76
84
  const command = new client_s3_1.DeleteObjectCommand({ Bucket: this.bucket, Key: key });
@@ -114,3 +122,8 @@ class S3Adapter {
114
122
  }
115
123
  }
116
124
  exports.S3Adapter = S3Adapter;
125
+ // An attachment with its name: ASCII in `filename`, the full name in `filename*` (RFC 6266).
126
+ const contentDisposition = (name) => {
127
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
128
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
129
+ };
@@ -5,6 +5,7 @@ exports.createMcpServer = createMcpServer;
5
5
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
6
6
  const tools_1 = require("./tools");
7
7
  const audit_1 = require("./audit");
8
+ const workflowNodes_1 = require("./workflowNodes");
8
9
  exports.MCP_SERVER_NAME = 'streamby';
9
10
  const errorText = (body, status) => {
10
11
  const message = body?.message ?? body?.error;
@@ -34,6 +35,17 @@ function createMcpServer(auth, api, config, version) {
34
35
  const projectId = tool.projectOf?.(args) ?? tokenProject;
35
36
  let status = null;
36
37
  try {
38
+ // What the workflow needs first, as a user would add it.
39
+ if (tool.workflowNode) {
40
+ const refused = await (0, workflowNodes_1.ensureWorkflowNode)(tool.workflowNode, tool.projectOf?.(args), auth, api);
41
+ if (refused) {
42
+ await (0, audit_1.recordMcpCall)({
43
+ userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
44
+ ok: false, status: null, error: refused, durationMs: Date.now() - started,
45
+ }, config);
46
+ return { isError: true, content: [{ type: 'text', text: refused }] };
47
+ }
48
+ }
37
49
  const answer = await api(tool.call(args));
38
50
  status = answer.status;
39
51
  const ok = status >= 200 && status < 300;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ApiTokenPermission } from '../services/apiTokenPermissions';
3
+ import type { WorkflowNodeRequirement } from './workflowNodes';
3
4
  export interface ToolCall {
4
5
  method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
5
6
  path: string;
@@ -12,6 +13,7 @@ export interface ToolDefinition<S extends z.ZodRawShape = z.ZodRawShape> {
12
13
  permissions: ApiTokenPermission[];
13
14
  write?: boolean;
14
15
  destructive?: boolean;
16
+ workflowNode?: WorkflowNodeRequirement;
15
17
  input: S;
16
18
  projectOf?: (args: z.infer<z.ZodObject<S>>) => string | undefined;
17
19
  call: (args: z.infer<z.ZodObject<S>>) => ToolCall;
package/dist/mcp/tools.js CHANGED
@@ -8,19 +8,20 @@ const projectId = id('project');
8
8
  const enc = encodeURIComponent;
9
9
  const byProject = ({ projectId }) => projectId;
10
10
  const CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
11
+ const STATUSES = ['todo', 'in_progress', 'review', 'done'];
11
12
  const shotFields = {
12
13
  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"),
14
+ status: zod_1.z.enum(STATUSES).optional(),
15
+ assignedTo: zod_1.z.array(zod_1.z.string()).optional().describe("The assignees' user ids: members of the project"),
15
16
  assetId: zod_1.z.string().optional(),
16
17
  exportId: zod_1.z.string().optional(),
17
18
  dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
18
19
  order: zod_1.z.number().optional(),
19
20
  };
20
21
  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"),
22
+ status: zod_1.z.enum(STATUSES).optional(),
23
+ priority: zod_1.z.enum(['low', 'medium', 'high']).optional(),
24
+ assignedTo: zod_1.z.string().optional().describe("The assignee's user id: a member of the project"),
24
25
  dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
25
26
  notes: zod_1.z.string().optional(),
26
27
  };
@@ -156,21 +157,21 @@ exports.TOOLS = [
156
157
  // ── Writing ──
157
158
  tool({
158
159
  name: 'update_asset_metadata', title: "Update an asset's custom tags", permissions: ['assets:write'], write: true,
159
- description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had.",
160
+ description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had. They show in the file's Metadata tab, in Storage.",
160
161
  input: { projectId, fileId: id('asset'), customTags: zod_1.z.record(zod_1.z.string(), zod_1.z.string()) },
161
162
  projectOf: byProject,
162
163
  call: ({ projectId, fileId, customTags }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata`, body: { customTags } }),
163
164
  }),
164
165
  tool({
165
166
  name: 'set_asset_rights', title: "Set an asset's rights", permissions: ['rights:write'], write: true,
166
- description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes.",
167
+ description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes. They show in the file's Rights tab, in Storage.",
167
168
  input: { projectId, assetId: id('asset'), ...rightsFields },
168
169
  projectOf: byProject,
169
170
  call: ({ projectId, assetId, ...rights }) => ({ method: 'PUT', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights`, body: defined(rights) }),
170
171
  }),
171
172
  tool({
172
173
  name: 'create_pipeline', title: 'Create a pipeline', permissions: ['workflow:write'], write: true,
173
- description: 'Creates a pipeline in a project.',
174
+ description: "Creates a pipeline in a project. It shows under Pipelines, and the workflow canvas draws its node.",
174
175
  input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
175
176
  projectOf: byProject,
176
177
  call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/pipelines`, body: defined(fields) }),
@@ -183,8 +184,8 @@ exports.TOOLS = [
183
184
  call: ({ projectId, pipelineId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}`, body: defined(fields) }),
184
185
  }),
185
186
  tool({
186
- name: 'create_sequence', title: 'Create a production sequence', permissions: ['production:write'], write: true,
187
- description: 'Creates a production sequence in a project.',
187
+ name: 'create_sequence', title: 'Create a production sequence', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
188
+ description: "Creates a production sequence in a project, shown in its Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
188
189
  input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
189
190
  projectOf: byProject,
190
191
  call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences`, body: defined(fields) }),
@@ -197,8 +198,8 @@ exports.TOOLS = [
197
198
  call: ({ projectId, sequenceId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}`, body: defined(fields) }),
198
199
  }),
199
200
  tool({
200
- name: 'create_shot', title: 'Create a shot', permissions: ['production:write'], write: true,
201
- description: 'Creates a shot in a production sequence.',
201
+ name: 'create_shot', title: 'Create a shot', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
202
+ description: "Creates a shot in a production sequence, shown in its Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
202
203
  input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1), ...shotFields },
203
204
  projectOf: byProject,
204
205
  call: ({ projectId, sequenceId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots`, body: defined(fields) }),
@@ -211,8 +212,8 @@ exports.TOOLS = [
211
212
  call: ({ projectId, shotId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}`, body: defined(fields) }),
212
213
  }),
213
214
  tool({
214
- name: 'create_task', title: 'Create a shot task', permissions: ['production:write'], write: true,
215
- description: 'Creates a task in a shot.',
215
+ name: 'create_task', title: 'Create a shot task', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
216
+ description: "Creates a task in a shot, shown in the shot's panel in the Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
216
217
  input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1), ...taskFields },
217
218
  projectOf: byProject,
218
219
  call: ({ projectId, shotId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks`, body: defined(fields) }),
@@ -224,6 +225,98 @@ exports.TOOLS = [
224
225
  projectOf: byProject,
225
226
  call: ({ projectId, shotId, taskId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks/${enc(taskId)}`, body: defined(fields) }),
226
227
  }),
228
+ // ── Files: uploading, downloading, deliverables ──
229
+ // The file itself never goes through the conversation: these tools give a signed URL, and
230
+ // the user's own tool (Blender's or Godot's MCP, a script) uploads or downloads it directly.
231
+ tool({
232
+ name: 'list_asset_versions', title: "List an asset's versions", permissions: ['assets:read'],
233
+ description: "An asset's versions, newest first: each one's id, number, file name and when it was made. Any of them can be downloaded (get_asset_download_url).",
234
+ input: { projectId, assetId: id('asset') },
235
+ projectOf: byProject,
236
+ call: ({ projectId, assetId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/versions` }),
237
+ pick: (data) => (data?.versions ?? []).map((v) => ({
238
+ versionId: v.versionId, version: v.version, fileName: v.fileName ?? null, contentType: v.contentType ?? null, createdAt: v.createdAt,
239
+ })),
240
+ }),
241
+ tool({
242
+ name: 'request_asset_upload', title: 'Request an upload URL', permissions: ['assets:write'], write: true,
243
+ description: [
244
+ "Makes a new asset (or, with assetId, a new version of one) and gives a signed URL to upload its file to, as the UI does.",
245
+ "It doesn't upload anything: the file is sent by the tool that has it (for example Blender's MCP, running Python), with an HTTP PUT of its bytes to `upload.url` and the `upload.headers` given, before `expiresAt`.",
246
+ "The URL works for that file only. A new version keeps the asset's URL (it replaces what it serves); the earlier ones are archived and can still be downloaded.",
247
+ "Categories: images, audios, videos, 3d-models (.glb, .gltf, .obj, .fbx, .stl, .ply); contentType must match it.",
248
+ ].join(' '),
249
+ input: {
250
+ projectId,
251
+ category: zod_1.z.enum(CATEGORIES),
252
+ fileName: zod_1.z.string().min(1).describe('The file name, with its extension (e.g. shot010_layout.glb)'),
253
+ contentType: zod_1.z.string().min(1).describe('Its MIME type (e.g. model/gltf-binary, image/png, video/mp4)'),
254
+ assetId: zod_1.z.string().optional().describe('An existing asset to upload a new version of; a new asset if not given'),
255
+ connectionId: zod_1.z.string().optional().describe("A storage connection to store it in; the project's built-in storage if not given"),
256
+ },
257
+ projectOf: byProject,
258
+ call: ({ projectId, category, fileName, contentType, assetId, connectionId }) => {
259
+ const query = new URLSearchParams({ fileName, contentType, ...(connectionId && !assetId ? { category } : {}) });
260
+ const files = connectionId ? `/projects/${enc(projectId)}/connections/storage/${enc(connectionId)}` : `/projects/${enc(projectId)}/storage`;
261
+ const path = assetId
262
+ ? `${files}/files/${enc(assetId)}/replace-url`
263
+ : connectionId ? `${files}/upload-url` : `${files}/${category}/upload-url`;
264
+ return { method: 'GET', path: `${path}?${query}` };
265
+ },
266
+ pick: (data, { contentType }) => ({
267
+ assetId: data?.fileId,
268
+ ...(data?.versionId ? { versionId: data.versionId, version: data.version } : { version: 1 }),
269
+ upload: { method: 'PUT', url: data?.url, headers: { 'Content-Type': contentType } },
270
+ expiresAt: data?.expiresAt ?? null,
271
+ }),
272
+ }),
273
+ tool({
274
+ name: 'get_asset_download_url', title: 'Get a download URL', permissions: ['assets:read'],
275
+ description: "A short-lived signed URL to download an asset's file (its latest version, or the one given), for the tool that will use it (for example Blender's or Godot's MCP) to fetch with an HTTP GET before `expiresAt`. It works for that file only.",
276
+ input: { projectId, assetId: id('asset'), versionId: zod_1.z.string().optional().describe('A version (list_asset_versions); the latest if not given') },
277
+ projectOf: byProject,
278
+ call: ({ projectId, assetId, versionId }) => ({
279
+ method: 'GET',
280
+ path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/download-url${versionId ? `?${new URLSearchParams({ versionId })}` : ''}`,
281
+ }),
282
+ }),
283
+ tool({
284
+ name: 'list_deliverables', title: 'List deliverables', permissions: ['assets:read'],
285
+ description: "A project's deliverables (assets delivered for a shot or an export), of one shot or export if given: each one's asset, version, note and what it's for.",
286
+ input: {
287
+ projectId,
288
+ shotId: zod_1.z.string().optional().describe("A shot's id: only its deliverables"),
289
+ exportId: zod_1.z.string().optional().describe("An export's id: only its deliverables"),
290
+ },
291
+ projectOf: byProject,
292
+ call: ({ projectId, shotId, exportId }) => {
293
+ const query = new URLSearchParams(defined({ shotId, exportId })).toString();
294
+ return { method: 'GET', path: `/projects/${enc(projectId)}/deliverables${query ? `?${query}` : ''}` };
295
+ },
296
+ pick: (data) => data?.deliverables ?? [],
297
+ }),
298
+ tool({
299
+ name: 'mark_deliverable', title: 'Mark a deliverable', permissions: ['assets:write'], write: true,
300
+ description: "Marks an asset (or one of its versions) as a deliverable of a production shot or of an export. It shows in the shot's panel in the Production board, or in the export's Deliverables tab. Give shotId or exportId, one of them. Upload the file first (request_asset_upload).",
301
+ input: {
302
+ projectId,
303
+ assetId: id('asset'),
304
+ versionId: zod_1.z.string().optional().describe('A version of it; without one, the asset as it is (its latest version)'),
305
+ shotId: zod_1.z.string().optional().describe("The shot it's delivered for"),
306
+ exportId: zod_1.z.string().optional().describe("The export it's delivered for"),
307
+ note: zod_1.z.string().max(1000).optional(),
308
+ },
309
+ projectOf: byProject,
310
+ call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/deliverables`, body: defined(fields) }),
311
+ pick: (data) => data?.deliverable,
312
+ }),
313
+ tool({
314
+ name: 'remove_deliverable', title: 'Remove a deliverable', permissions: ['assets:write'], write: true,
315
+ description: 'Unmarks a deliverable. Its asset stays where it is.',
316
+ input: { projectId, deliverableId: id('deliverable') },
317
+ projectOf: byProject,
318
+ call: ({ projectId, deliverableId }) => ({ method: 'DELETE', path: `/projects/${enc(projectId)}/deliverables/${enc(deliverableId)}` }),
319
+ }),
227
320
  // ── Projects ──
228
321
  tool({
229
322
  name: 'create_project', title: 'Create a project', permissions: ['projects:create'], write: true,
@@ -0,0 +1,24 @@
1
+ import { Auth } from '../types';
2
+ import type { ApiCaller } from './server';
3
+ export type WorkflowNodeRequirement = 'production';
4
+ /** A Shot node, as the UI makes one dropped on the canvas without a position. */
5
+ export declare const newShotNode: () => {
6
+ id: string;
7
+ type: string;
8
+ position: {
9
+ x: number;
10
+ y: number;
11
+ };
12
+ data: {
13
+ label: string;
14
+ subtitle: string;
15
+ bgColor: string;
16
+ iconColor: string;
17
+ };
18
+ };
19
+ /**
20
+ * Makes sure a project's workflow has what `requirement` needs, adding it (through the REST
21
+ * API, as the caller) if it's missing. An error to answer with, or null to go on. A project
22
+ * the caller doesn't reach is left alone: the call that follows answers for it.
23
+ */
24
+ export declare function ensureWorkflowNode(requirement: WorkflowNodeRequirement, projectId: string | undefined, auth: Auth, api: ApiCaller): Promise<string | null>;
@@ -0,0 +1,48 @@
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.newShotNode = void 0;
7
+ exports.ensureWorkflowNode = ensureWorkflowNode;
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const manager_1 = require("../models/manager");
10
+ const auth_1 = require("../utils/auth");
11
+ const auth_2 = require("../services/auth");
12
+ // The node types that make a workflow's production section (as the UI groups them).
13
+ const PRODUCTION_NODE_TYPES = new Set(['shotNode', 'assemblyNode', 'masterNode']);
14
+ /** A Shot node, as the UI makes one dropped on the canvas without a position. */
15
+ const newShotNode = () => ({
16
+ id: `shotNode-${crypto_1.default.randomUUID()}`,
17
+ type: 'shotNode',
18
+ position: { x: 240 + Math.random() * 60, y: 300 + Math.random() * 40 },
19
+ data: { label: 'Shot', subtitle: 'Raw video clip', bgColor: '#0e2537', iconColor: '#38B6FF' },
20
+ });
21
+ exports.newShotNode = newShotNode;
22
+ /**
23
+ * Makes sure a project's workflow has what `requirement` needs, adding it (through the REST
24
+ * API, as the caller) if it's missing. An error to answer with, or null to go on. A project
25
+ * the caller doesn't reach is left alone: the call that follows answers for it.
26
+ */
27
+ async function ensureWorkflowNode(requirement, projectId, auth, api) {
28
+ if (requirement !== 'production' || !projectId)
29
+ return null;
30
+ if (!(0, auth_2.scopeReachesProject)(auth.apiToken, projectId))
31
+ return null;
32
+ const project = await (0, manager_1.getModel)('projects').findOne({ _id: projectId });
33
+ if (!project || !(0, auth_1.activeMemberOf)(project, auth.userId))
34
+ return null;
35
+ const schema = project.workflow?.nodeSchema ?? null;
36
+ const nodes = Array.isArray(schema?.nodes) ? schema.nodes : [];
37
+ if (nodes.some(node => PRODUCTION_NODE_TYPES.has(node?.type)))
38
+ return null;
39
+ const answer = await api({
40
+ method: 'PATCH',
41
+ path: `/projects/${encodeURIComponent(projectId)}/workflow`,
42
+ body: { nodeSchema: { ...(schema ?? {}), nodes: [...nodes, (0, exports.newShotNode)()], edges: Array.isArray(schema?.edges) ? schema.edges : [] } },
43
+ });
44
+ if (answer.status >= 200 && answer.status < 300)
45
+ return null;
46
+ const message = answer.body?.message ?? answer.body?.error;
47
+ return `Couldn't add a production node to the project's workflow: ${typeof message === 'string' ? message : `StreamBy answered ${answer.status}`}`;
48
+ }
@@ -38,6 +38,7 @@ const production_1 = require("./routes/production");
38
38
  const rights_1 = require("./routes/rights");
39
39
  const workflow_1 = require("./routes/workflow");
40
40
  const pipeline_1 = require("./routes/pipeline");
41
+ const deliverable_1 = require("./routes/deliverable");
41
42
  function createStreamByRouter(config) {
42
43
  const router = express_1.default.Router();
43
44
  if (config.encrypt)
@@ -134,5 +135,6 @@ function createStreamByRouter(config) {
134
135
  router.use((0, rights_1.rightsRouter)(config));
135
136
  router.use((0, workflow_1.workflowRouter)(config));
136
137
  router.use((0, pipeline_1.pipelineRouter)(config));
138
+ router.use((0, deliverable_1.deliverableRouter)(config));
137
139
  return router;
138
140
  }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function deliverableRouter(_config: StreamByConfig): Router;