davinci-resolve-mcp 2.117.0 → 2.119.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.
@@ -48,6 +48,8 @@ window. `render.verify_output` covers the container-level checks.
48
48
  | Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
49
49
  | Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
50
50
  | Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
51
+ | Timeline markers | `spec.markers` (16 colors, notes, durations, customData) | v2.118 |
52
+ | Turnover markers | EDL `* LOC:` locators + OTIO markers → authored | v2.119 |
51
53
  | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
52
54
 
53
55
  `assemble_from_interchange` drives the same engine from an EDL / OTIO /
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.117.0"
40
+ VERSION = "2.119.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.117.0",
3
+ "version": "2.119.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -229,9 +229,11 @@ export function eventsToAssembleSpec(events, opts = {}) {
229
229
  }
230
230
  const DEFAULT_ORIGIN = 86400;
231
231
  const isAudio = (t) => /^A\d*$/.test(String(t || ''));
232
- const vids = events.filter((e) => !isAudio(e.track) && e.recIn != null && e.recOut != null);
232
+ const isMarker = (t) => t === 'MARKER';
233
+ const vids = events.filter((e) => !isAudio(e.track) && !isMarker(e.track) && e.recIn != null && e.recOut != null);
233
234
  const auds = events.filter((e) => isAudio(e.track) && e.recIn != null && e.recOut != null);
234
- const audioSkipped = events.length - vids.length - auds.length;
235
+ const markerEvents = events.filter((e) => isMarker(e.track) && e.recIn != null);
236
+ const audioSkipped = events.length - vids.length - auds.length - markerEvents.length;
235
237
  if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
236
238
 
237
239
  const unmapped = [...new Set([...vids, ...auds].map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
@@ -426,12 +428,31 @@ export function eventsToAssembleSpec(events, opts = {}) {
426
428
  transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, trackType: 'audio' });
427
429
  }
428
430
 
431
+ // Turnover markers (EDL * LOC: locators, OTIO Marker objects) → authored
432
+ // timeline markers. Interchange colors map to the measured Resolve names;
433
+ // unknown colors fall back to Blue. Frames are timeline-absolute like cuts.
434
+ const COLOR_MAP = {
435
+ blue: 'Blue', cyan: 'Cyan', green: 'Green', yellow: 'Yellow', red: 'Red',
436
+ pink: 'Pink', purple: 'Purple', magenta: 'Fuchsia', fuchsia: 'Fuchsia',
437
+ rose: 'Rose', lavender: 'Lavender', sky: 'Sky', mint: 'Mint',
438
+ lemon: 'Lemon', sand: 'Sand', cocoa: 'Cocoa', cream: 'Cream',
439
+ orange: 'Sand', white: 'Cream', black: 'Cocoa',
440
+ };
441
+ const markers = markerEvents
442
+ .map((e) => ({
443
+ frame: ORIGIN + (toTl(e.recIn, e.fps) - minRec),
444
+ color: COLOR_MAP[String(e.color || '').toLowerCase()] || 'Blue',
445
+ ...(e.name ? { name: e.name } : {}),
446
+ }))
447
+ .filter((m) => m.frame >= (preserveStartTimecode ? startFrame : DEFAULT_ORIGIN));
448
+
429
449
  return {
430
- spec: { timelineName, media, ...(preserveStartTimecode ? { startFrame } : {}), ...(transitions.length ? { transitions } : {}) },
450
+ spec: { timelineName, media, ...(preserveStartTimecode ? { startFrame } : {}), ...(markers.length ? { markers } : {}), ...(transitions.length ? { transitions } : {}) },
431
451
  report: {
432
452
  videoEvents: vids.length,
433
453
  sources: media.length,
434
454
  audioEventsSkipped: audioSkipped,
455
+ authoredMarkers: markers.length,
435
456
  authoredAudioEvents: audioPlacements.length,
436
457
  audioRetimesSkipped,
437
458
  upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
@@ -49,6 +49,8 @@ function evt(o) {
49
49
  return {
50
50
  index: o.index ?? null,
51
51
  track: o.track || 'V',
52
+ ...(o.name !== undefined ? { name: o.name } : {}),
53
+ ...(o.color !== undefined ? { color: o.color } : {}),
52
54
  source: o.source || 'UNKNOWN',
53
55
  srcIn: o.srcIn ?? null,
54
56
  srcOut: o.srcOut ?? null,
@@ -83,6 +85,19 @@ export function parseEDL(text, opts = {}) {
83
85
  }
84
86
  continue;
85
87
  }
88
+ // Avid-style locators: `* LOC: 01:00:01:12 BLUE marker text` — a marker
89
+ // at an absolute record timecode. Emitted as track 'MARKER' pseudo-events
90
+ // (recIn only) so the assemble bridge can author them; consumers that
91
+ // filter video/audio by track shape ignore them.
92
+ const loc = /^\*\s*LOC:\s*(\d{2}:\d{2}:\d{2}[:;]\d{2})\s+(\S+)\s*(.*)$/i.exec(line);
93
+ if (loc) {
94
+ events.push(evt({
95
+ index: events.length + 1, track: 'MARKER', source: '',
96
+ recIn: tcToFrames(loc[1], fps), recOut: null,
97
+ name: loc[3].trim() || undefined, color: loc[2], fps,
98
+ }));
99
+ continue;
100
+ }
86
101
  const tokens = line.split(/\s+/);
87
102
  if (!/^\d+$/.test(tokens[0])) continue; // not an event line
88
103
  const tcs = tokens.filter(isTc);
@@ -147,6 +162,14 @@ export function parseOTIO(otio, opts = {}) {
147
162
  }
148
163
  }
149
164
  const src = (child.media_reference && (child.media_reference.target_url || child.media_reference.name)) || child.name || 'UNKNOWN';
165
+ for (const mk of child.markers || []) {
166
+ const mrStart = (mk.marked_range && mk.marked_range.start_time && mk.marked_range.start_time.value) || 0;
167
+ events.push(evt({
168
+ index: idx++, track: 'MARKER', source: '',
169
+ recIn: rec + (mrStart - startVal), recOut: null,
170
+ name: mk.name || undefined, color: mk.color || undefined, fps: rate,
171
+ }));
172
+ }
150
173
  events.push(
151
174
  evt({
152
175
  index: idx++,
@@ -48,7 +48,7 @@ const assembleSchema = z.object({
48
48
  spec: z
49
49
  .object({})
50
50
  .passthrough()
51
- .describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
51
+ .describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
52
52
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
53
53
  targetAppVersion: z
54
54
  .union([z.string(), z.number()])
@@ -26,6 +26,7 @@ const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = requir
26
26
  const JSZip = require('jszip');
27
27
  const { cutSourceIntoClips } = require('./cut-media');
28
28
  const { buildConstantSpeedTimemapKeyed } = require('./media-timemap');
29
+ const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
29
30
  const { randomUUID } = require('node:crypto');
30
31
  const { placeFusionTitle } = require('./place-fusion-title');
31
32
  const { placeGenerator } = require('./place-generator');
@@ -192,6 +193,33 @@ async function assembleTimeline(spec = {}) {
192
193
  }));
193
194
  }
194
195
 
196
+ if (Array.isArray(spec.markers) && spec.markers.length) {
197
+ // Timeline markers ride in project.xml as a Sm2SequenceLockableBlob whose
198
+ // BlobOwner is the timeline's Sm2Sequence DbId (the uuid every track's
199
+ // <Sequence> references). Encoder byte-exact vs a live 19.1.3.7 export.
200
+ // Marker frames here are TIMELINE-ABSOLUTE for consistency with cuts;
201
+ // the blob stores them start-relative.
202
+ const zipM = await JSZip.loadAsync(buffer);
203
+ const seqName = Object.keys(zipM.files).find((n) => !zipM.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
204
+ const seqXml2 = await zipM.file(seqName).async('string');
205
+ const seqIdM = (seqXml2.match(/<Sequence>([0-9a-f-]{36})<\/Sequence>/) || [])[1];
206
+ if (!seqIdM) throw new Error('assembleTimeline: cannot find the Sm2Sequence id for markers');
207
+ const rel = spec.markers.map((m) => {
208
+ if (!Number.isInteger(m.frame) || m.frame < originFrame) {
209
+ throw new RangeError(`assembleTimeline: marker frame ${m.frame} is before the timeline origin ${originFrame} (frames are timeline-absolute)`);
210
+ }
211
+ return { ...m, frame: m.frame - originFrame };
212
+ });
213
+ const blob = encodeTimelineMarkersBlob(rel);
214
+ let pjX = await zipM.file('project.xml').async('string');
215
+ const setM = pjX.match(/<LocableBlobSet>[\s\S]*?<\/LocableBlobSet>/);
216
+ if (!setM) throw new Error('assembleTimeline: project.xml has no LocableBlobSet to hold markers');
217
+ const el = `<Element>\n <Sm2SequenceLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${seqIdM}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2SequenceLockableBlob>\n </Element>\n `;
218
+ pjX = pjX.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
219
+ zipM.file('project.xml', pjX);
220
+ buffer = await zipM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
221
+ }
222
+
195
223
  if (originFrame !== DEFAULT_START_FRAME) {
196
224
  const zipF = await JSZip.loadAsync(buffer);
197
225
  const mpP = 'MediaPool/Master/MpFolder.xml';
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * DaVinci Resolve Timeline Marker Encoder/Decoder
3
3
  *
4
+ * DEPRECATED for .drp/.drt authoring: this module's color map and emitted
5
+ * bytes never matched a real Resolve export (measured 2026-08-30 — Yellow is
6
+ * 16 not 8, Purple is 128, and the framing differs). Use
7
+ * timeline-markers-blob.js, whose encoder is byte-exact against a live
8
+ * 19.1.3.7 export and render/readback-verified through drt.assemble.
9
+ *
4
10
  * Encodes and decodes timeline markers into DaVinci Resolve's
5
11
  * compressed protobuf format stored in Sm2SequenceLockableBlob.FieldsBlob.
6
12
  *
@@ -0,0 +1,159 @@
1
+ /**
2
+ * timeline-markers-blob — encode/decode TIMELINE markers as the
3
+ * Sm2SequenceLockableBlob.FieldsBlob that lives in project.xml.
4
+ *
5
+ * Ground truth (harvested live from Studio 19.1.3.7, all 16 colors + custom
6
+ * data + durations, decoded round-trip against the API's own readback):
7
+ *
8
+ * FieldsBlob = keyed-dict { "BlobData": bytes }
9
+ * BlobData = [u32BE 10001][u32BE innerLen][0x81][zstd frame]
10
+ * zstd frame = magic 28b52ffd + single-segment header + RAW block(s)
11
+ * (Resolve itself emits raw-block zstd for small payloads —
12
+ * accepted on import; no real compressor needed)
13
+ * payload = protobuf: field2 { repeated field1 MarkerEntry }
14
+ * MarkerEntry= f1 varint frameRelative, f2 bytes {
15
+ * [u32BE 2][u32BE innerLen] f1 bytes {
16
+ * f1 varint colorBit,
17
+ * f3 string note, f3 string durationString, f3 string name,
18
+ * f6 string customData (present only when non-empty)
19
+ * } }
20
+ *
21
+ * Marker frames are RELATIVE to the timeline start (frame 0 = first frame),
22
+ * matching the scripting API's marker frame space. The blob attaches inside
23
+ * project.xml's <LocableBlobSet> (Resolve's own spelling) with
24
+ * <BlobOwner> = the timeline's Sm2Sequence DbId (the same uuid every track's
25
+ * <Sequence> references).
26
+ *
27
+ * Color bits (measured, one marker per color): sequential powers of two with
28
+ * 256 unassigned. This supersedes marker-encoder.js, whose map was wrong for
29
+ * Yellow/Purple/Lavender and whose emitted bytes never matched a real export.
30
+ *
31
+ * @module drp-format/timeline-markers-blob
32
+ */
33
+
34
+ const { encodeKeyedDict, decodeKeyedDict } = require('./keyed-dict');
35
+
36
+ const MARKER_COLOR_BITS = {
37
+ Blue: 2, Cyan: 4, Green: 8, Yellow: 16, Red: 32, Pink: 64, Purple: 128,
38
+ Fuchsia: 512, Rose: 1024, Lavender: 2048, Sky: 4096, Mint: 8192,
39
+ Lemon: 16384, Sand: 32768, Cocoa: 65536, Cream: 131072,
40
+ };
41
+ const BITS_TO_COLOR = Object.fromEntries(Object.entries(MARKER_COLOR_BITS).map(([k, v]) => [v, k]));
42
+
43
+ function varint(n) {
44
+ const out = [];
45
+ let v = n >>> 0;
46
+ do { out.push((v & 0x7f) | (v > 0x7f ? 0x80 : 0)); v >>>= 7; } while (v);
47
+ return Buffer.from(out);
48
+ }
49
+ const lenDelim = (field, payload) => Buffer.concat([varint((field << 3) | 2), varint(payload.length), payload]);
50
+ const varField = (field, n) => Buffer.concat([varint(field << 3), varint(n)]);
51
+
52
+ function encodeMarkerEntry(m) {
53
+ const colorBit = MARKER_COLOR_BITS[m.color] ?? MARKER_COLOR_BITS.Blue;
54
+ const strs = [m.note ?? '', String(m.duration ?? 1), m.name ?? ''];
55
+ const body = Buffer.concat([
56
+ varField(1, colorBit),
57
+ ...strs.map((s) => lenDelim(3, Buffer.from(s, 'utf8'))),
58
+ ...(m.customData ? [lenDelim(6, Buffer.from(m.customData, 'utf8'))] : []),
59
+ ]);
60
+ const inner = lenDelim(1, body);
61
+ const head = Buffer.alloc(8);
62
+ head.writeUInt32BE(2, 0);
63
+ head.writeUInt32BE(inner.length, 4);
64
+ const wrapped = Buffer.concat([head, inner]);
65
+ return lenDelim(1, Buffer.concat([varField(1, m.frame), lenDelim(2, wrapped)]));
66
+ }
67
+
68
+ /** zstd single-segment frame with one RAW block (no compression). */
69
+ function zstdRawFrame(payload) {
70
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
71
+ let header;
72
+ if (payload.length <= 255) {
73
+ header = Buffer.from([0x20, payload.length]); // single-segment, 1-byte FCS
74
+ } else {
75
+ header = Buffer.alloc(5);
76
+ header[0] = 0xa0; // single-segment, 4-byte FCS
77
+ header.writeUInt32LE(payload.length, 1);
78
+ }
79
+ const block = Buffer.alloc(3);
80
+ block.writeUIntLE((payload.length << 3) | 1, 0, 3); // last=1, type=raw
81
+ return Buffer.concat([magic, header, block, payload]);
82
+ }
83
+
84
+ function zstdRawInflate(buf) {
85
+ if (buf.readUInt32LE(0) !== 0xfd2fb528) throw new Error('timeline-markers-blob: not a zstd frame');
86
+ const fhd = buf[4];
87
+ const single = (fhd >> 5) & 1;
88
+ const fcsCode = fhd >> 6;
89
+ let o = 5 + (single ? [1, 2, 4, 8][fcsCode] : [0, 2, 4, 8][fcsCode]);
90
+ if (fhd & 0x03) throw new Error('timeline-markers-blob: dictionary frames unsupported');
91
+ const out = [];
92
+ for (;;) {
93
+ const bh = buf.readUIntLE(o, 3); o += 3;
94
+ const last = bh & 1, type = (bh >> 1) & 3, size = bh >> 3;
95
+ if (type === 0) { out.push(buf.subarray(o, o + size)); o += size; }
96
+ else if (type === 1) { out.push(Buffer.alloc(size, buf[o])); o += 1; }
97
+ else throw new Error('timeline-markers-blob: compressed zstd block — use a real zstd decoder');
98
+ if (last) break;
99
+ }
100
+ return Buffer.concat(out);
101
+ }
102
+
103
+ /**
104
+ * Encode timeline markers → Sm2SequenceLockableBlob FieldsBlob buffer.
105
+ * @param {Array<{frame:number,color?:string,name?:string,note?:string,duration?:number,customData?:string}>} markers
106
+ * frame is timeline-RELATIVE (0 = first frame of the timeline).
107
+ */
108
+ function encodeTimelineMarkersBlob(markers) {
109
+ for (const m of markers) {
110
+ if (!Number.isInteger(m.frame) || m.frame < 0) throw new TypeError('encodeTimelineMarkersBlob: marker.frame must be a non-negative integer (timeline-relative)');
111
+ if (m.color && !MARKER_COLOR_BITS[m.color]) {
112
+ throw new Error(`encodeTimelineMarkersBlob: unknown color "${m.color}" (known: ${Object.keys(MARKER_COLOR_BITS).join(', ')})`);
113
+ }
114
+ }
115
+ const entries = [...markers].sort((a, b) => b.frame - a.frame).map(encodeMarkerEntry);
116
+ const pb = lenDelim(2, Buffer.concat(entries));
117
+ const frame = zstdRawFrame(pb);
118
+ const head = Buffer.alloc(8);
119
+ head.writeUInt32BE(10001, 0);
120
+ head.writeUInt32BE(frame.length + 1, 4);
121
+ const blobData = Buffer.concat([head, Buffer.from([0x81]), frame]);
122
+ return encodeKeyedDict({ hdr: 1, entries: [
123
+ { key: 'BlobData', type: 0x0c, subType: 0, value: blobData.toString('hex') },
124
+ ] });
125
+ }
126
+
127
+ /** Decode a Sm2SequenceLockableBlob FieldsBlob → markers (raw/RLE zstd only). */
128
+ function decodeTimelineMarkersBlob(buf) {
129
+ const d = decodeKeyedDict(buf);
130
+ const bd = d.entries.find((e) => e.key === 'BlobData');
131
+ if (!bd) throw new Error('decodeTimelineMarkersBlob: no BlobData entry');
132
+ const val = Buffer.from(bd.value, 'hex');
133
+ if (val.readUInt32BE(0) !== 10001 || val[8] !== 0x81) throw new Error('decodeTimelineMarkersBlob: unexpected BlobData framing');
134
+ const pb = zstdRawInflate(val.subarray(9));
135
+ let o = 0;
136
+ const rv = () => { let v = 0, s = 0; for (;;) { const b = pb[o++]; v |= (b & 0x7f) << s; if (!(b & 0x80)) return v >>> 0; s += 7; } };
137
+ const markers = [];
138
+ if (pb[o] === 0x12) { o++; rv(); }
139
+ while (o < pb.length && pb[o] === 0x0a) {
140
+ o++; const el = rv(); const end = o + el;
141
+ const m = { frame: null, color: null, note: '', duration: 1, name: '', customData: '' };
142
+ if (pb[o] === 0x08) { o++; m.frame = rv(); }
143
+ if (pb[o] === 0x12) {
144
+ o++; rv(); o += 8;
145
+ if (pb[o] === 0x0a) { o++; rv(); }
146
+ if (pb[o] === 0x08) { o++; m.color = BITS_TO_COLOR[rv()] ?? null; }
147
+ const strs = [];
148
+ while (o < end && pb[o] === 0x1a) { o++; const sl = rv(); strs.push(pb.subarray(o, o + sl).toString('utf8')); o += sl; }
149
+ [m.note = '', , m.name = ''] = strs;
150
+ m.duration = parseInt(strs[1] ?? '1', 10) || 1;
151
+ if (o < end && pb[o] === 0x32) { o++; const sl = rv(); m.customData = pb.subarray(o, o + sl).toString('utf8'); o += sl; }
152
+ }
153
+ o = end;
154
+ markers.push(m);
155
+ }
156
+ return markers;
157
+ }
158
+
159
+ module.exports = { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob, MARKER_COLOR_BITS };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.117.0"
90
+ VERSION = "2.119.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.117.0"
14
+ VERSION = "2.119.0"
15
15
 
16
16
  import base64
17
17
  import os