An MCP server that turns a folder of clips and a JSON manifest into a finished DaVinci Resolve project — and then checks its own work.
Point it at a package and you get: a new project from your template, the media
imported into its bins, every clip on V1 in shot order, a named marker on
each shot, narration laid on its own audio lanes at the right frames, an
optional colour balance on every clip — and an acceptance gate that reports
PASS or FAIL per check rather than assuming any of it worked.
- Builds the project from your own
.drptemplate, so settings and the bin tree are yours, not this tool's. - Imports the package into the right bins, by name.
- Lays the timeline — clips in shot order on V1, one marker per shot carrying its number and description.
- Places narration on its own lanes — board-wide takes on
VO, takes pinned to a shot onVO PINS, at that shot's exact frame. Never on A1, which holds the clips' own audio. - Applies a starting colour balance (ASC CDL on node 1) when the package carries a look — a matched starting point for the colourist, never a grade.
- Verifies the result and can fail. Clip order, stray clips, every pinned
take on its frame at zero tolerance, a marker per shot naming its own shot.
A build that went wrong is reported as
FAIL, not as success.
It never deletes, never overwrites, and never renders. An existing project or timeline name is a hard refusal.
No. The input is a plain folder plus a JSON manifest — nothing proprietary, no service to sign up for, no network calls. OSIDE Studio happens to write that manifest, but anything can: a script, another editor, your own tool.
A minimal oside-davinci/v1 package looks like this:
my-package/
manifest.json
videos/shot1.mp4
videos/shot2.mp4
vo/narration.mp3
{
"format": "oside-davinci/v1",
"kind": "cinematic",
"project": { "name": "MY FILM" },
"videos": [
{ "file": "videos/shot1.mp4", "bin": "VIDEOS", "sceneIndex": 1, "shotIndex": 1,
"shotNumber": "1A", "description": "Ada enters the diner." },
{ "file": "videos/shot2.mp4", "bin": "VIDEOS", "sceneIndex": 1, "shotIndex": 2,
"shotNumber": "2", "description": "Two-shot at the counter." }
],
"audio": [
{ "file": "vo/narration.mp3", "bin": "VO", "label": "Narrator",
"durationSeconds": 12.0, "placements": [] }
]
}placements: [] means a board-wide take (laid at the head of VO). To pin a
take under one shot, give it the shot it belongs to and it rides VO PINS on
that shot's first frame:
"placements": [{ "sceneIndex": 1, "shotIndex": 2, "shotNumber": "2",
"videoFile": "videos/shot2.mp4" }]Everything else — dialogue cues, in-frame text worklists, the look block — is optional and described under The tools.
- DaVinci Resolve Studio, running. The free edition has no external scripting and will never answer.
- Preferences → System → General → External scripting using: Local.
- Python 3.10+.
ffprobeonPATHis optional — it makes offline dry runs more accurate.
git clone https://github.com/OSideMedia/oside-resolve-mcp
cd oside-resolve-mcp
python3 -m venv .venv && .venv/bin/python -m pip install -e .Register it with your MCP client — for Claude Code, in .mcp.json:
{
"mcpServers": {
"oside-resolve": {
"command": "/absolute/path/to/oside-resolve-mcp/.venv/bin/python",
"args": ["/absolute/path/to/oside-resolve-mcp/server.py"]
}
}
}Then snapshot your templates once, with Resolve open. This server builds
projects from your .drp, so it needs one per kind in
templates/templates.json:
export_template("cinematic") # or, in Resolve: right-click the template
export_template("explainer") # project → Export Project → save to templates/
Verify the install with resolve_status() — it answers even when Resolve is
unreachable, and says why.
resolve_status() # preconditions + capabilities
build_timeline("…/manifest.json", dry_run=True, # the plan, before anything exists
project="MY FILM") # touches nothing
create_project("cinematic", "MY FILM")
import_package("…/manifest.json")
build_timeline("…/manifest.json") # V1 + VO lanes + markers
save_project() # the verification boundary
verify_import("…/manifest.json") # PASS | FAIL, per check
Always dry-run first. dry_run=True returns the whole plan — clip order,
what is on disk, where every VO take would land, every marker — and writes
nothing. It works with Resolve closed.
Save before you verify. An errored append's placements survive every in-session read and are discarded by the save, so a gate run before the save can pass over work that was never written.
Or run the whole thing as one command:
.venv/bin/python pipeline.py path/to/manifest.json --name "MY FILM"
.venv/bin/python pipeline.py path/to/manifest.json --dry-run # touches nothingIt prints a JSON report and exits non-zero unless the gate passes.
This is deliberately NOT general Resolve remote control. Blackmagic ships its
own MCP server with Studio 21.1, inside the app bundle at
DaVinci Resolve.app/Contents/Applications/ResolveMCP: arbitrary run_script
against the scripting API, plus search_scripting_api / get_scripting_api /
get_whats_new — version-matched to the Resolve you are running, and so the
owning source for call SHAPES.
The two do different jobs. Reach for Blackmagic's for API lookup, the changelog
past your training cutoff, and ad-hoc read-only probing. Reach for THIS one for
the handoff — a fixed pipeline gets fixed tools so it runs identically every
time, and the acceptance gate is the part a general tool cannot give you. The
first-party stubs document call shapes but none of the BEHAVIOURAL lies this
server is built around (AppendToTimeline still promises "the list of appended
timelineItems" over a silent drop), so ANTI-PATTERNS in CLAUDE.md still earns
its keep. While a build is running, treat the general server as read-only: two
writers, one Resolve.
samuelgursky/davinci-resolve-mcp
(MIT) predates both, and its connection pattern informed resolve_api.py.
The full surface, with the behaviour each one guarantees.
create_project(kind, name)— new project from the right template snapshot (cinematic→ YT-4K 23.976fps,explainer→ YT-4K 60fps). Settings and the whole bin tree (O-Side Media / VIDEOS / VO / …) come from the.drp.import_package(manifest_path)— videos into the VIDEOS bin, narration mp3s into the VO bin.build_timeline(manifest_path, timeline_name="EDIT 01", dry_run=False, cues=True, project=None)— clips in shot order on V1, one Blue point marker per shot carrying its number + description (+ VO line for explainers), and narration on its own audio lanes — never A1, which carries the shot clips' OWN audio: Seedance generates audio and video in one pass and OSIDE directs it, so A1 holds the dialogue, SFX and ambience the director specified. Narration is separate work and gets its own lanes, one per DOOR: a board-wide take (placements: []— the recorded spine, or a board narration) ridesVOat the head, and a take pinned to a shot ridesVO PINSon that shot's own frame. The two overlap in time — a spine is often as long as the finished piece — so a single track cannot hold both: laid together the spine went down first and every pin was refused to the tail, which failed verify on any package using both doors [measured 2026-09-08]. Pins that collide with EACH OTHER (a long take pinned to a short shot spilling into the next pin) checkerboard ontoVO PINS 2,VO PINS 3… — never a slide to the tail. And — when the manifest names adialogue-cues.csv— one range marker per scripted line under its shot: name = speaker, note = the line, length = the line's estimate (the shot's length when a cue has none), one colour per speaker (deterministic per project, never Blue).cues=Falseskips them. When the manifest also names atext-tasks.csv, one point marker per run of in-frame lettering lands on the shot that asked for it: name and note carry the exact wording, and the note says the generation deliberately did not render it — video models re-spell text between takes, so a sign belongs over the clip, not inside it. Placed AFTER that shot's cues (Resolve keeps one marker per frame), and a task with no free frame inside its own clip is REPORTED rather than nudged onto the next shot. Rides the samecuesswitch.dry_run=Truetouches nothing and returns the full plan — ordered clip list with on-disk / in-bin presence, VO placement per take (underShot/atHead, target shot, start frame,track: "VO"), shot + cue markers, amissinglist andwouldBuild— in the same shape the real call reports (planon both), so the two can be diffed. It works with Resolve closed (bin presence is then"unknown — Resolve not connected"; clip lengths come fromffprobewhen it is on PATH, or an explainer's beat timing).project=NAMEmakes the plan project-aware: when NAME is not the open project (the usual case — the dry run comes beforecreate_project), bin presence reads"unknown — not imported yet"(nevermissing),wouldBuildis judged on disk presence, andfpscomes from the kind's template (templates.json), not from whatever project happens to be open. Only when NAME is the open project do the bins count. Every VO placement in a real build is confirmed by re-reading the VO track — Resolve'sAppendToTimelineanswers a truthy list on a silent drop. 3b.apply_look(manifest_path, timeline_name=None, dry_run=False)— "look travels" (0.3.0). When the manifest carries alookblock (the studio project had a Style Constant with colour-law hexes), reads thelook-cdl.jsonDepth Converter measured beside it (depthc look-compare --manifest <manifest.json> --emit-cdl) and sets each clip's ASC CDL on node 1 of its V1 item — key, temperature, saturation only. A matched starting balance for the colourist; never a palette fix, never a creative grade (measured live 2026-08-16: 8 clips went from ΔL* −7…+9.5 to ±0.4 against the look, palette distance unchanged). Idempotent (absolute values). Refuses — applies nothing — without a look block or the CDL file, and says why. Returns per-clip rows{clip, shot, applied, identity, cdl, measured},missing,extra(V1 items the manifest does not know stay untouched). Resolve exposes noGetCDL, soappliedis only SetCDL's answer about itself —verify=True(0.5.0) READS THE GRADE BACK: it exports the timeline as an EDL carrying ASC CDL and compares what returns against what it set, per clip (verified, plusreadBackDiffswhen it does not match) with areadbacksummary.completethen requires the read-back to match, so a look that did not take can no longer report success. The EDL has no clip names — every event's reel isAX— so events match V1 items BY POSITION. A falseappliedcarries a diagnosis against the item's node count. Echoes Depth Converter's ownderivationline (the gains are a fitted HEURISTIC, and the file says so).dry_run=Truereturns the plan without touching Resolve. Film stock (0.3.1): when the look block carriesstockIntent, one marker goes on the head of the timeline —Stock intent: <label> (<balance>) — colourist's call, nothing applied. No node, no LUT, no Film Look Creator: a stock IS a creative grade and this tool sells a starting balance. It nudges past shot 1's marker and reports the frame it used; no room means a reported skip, never a silent drop.verify_import(manifest_path, timeline_name=None, cues=True)— the Since 0.5.3+ (step e) it also writesverify.jsonbeside the manifest — one row per clip bygenerationId(placed,start,duration),overall,verifiedAt; OSIDE's build door reads it back onto the generation row. acceptance gate: a per-check tablechecks: [{check, expected, found, pass, …}]andoverall: PASS|FAIL. Checks: every package file in its bin; V1 clip count; V1 clip order == manifest order (by clip name); no stray V1 clips; every pinned VO take on its shot's first frame (tolerance 0,deltareported; looked for on every audio track, and each VO row names thetrackit sits on, e.g."A2 VO"; unpinned takes pass on presence, since two head takes cannot share frame 0); one shot marker per shot, each on its shot's first frame; cue markers == scripted lines (0 whencues=False); text-task markers == rows in the worklist, a check that appears ONLY when the package carries one, so a package exported before the studio wrote them still passes. The pre-v2clean+reportfields stay (cleannow means overall PASS). v3 (0.5.0): each shot marker must NAME its own shot (the old row compared sorted frame lists, so rotating every marker onto a different clip passed); cue and text markers are scored against what the build INTENDED to place, so a deliberately reported skip is no longer a FAIL; narration must be OFF A1; a stock marker is required when the manifest names astockIntent; and a worklist the manifest names but which is not on disk is reported rather than silently passing.
Plus resolve_status (with a capabilities block — {"manifest": "oside-davinci/v1", "features": ["placements","cues","dry_run","verify_v2", "vo_track","look","text_tasks"], "version"} — returned even when Resolve is unreachable, so OSIDE can compare
before it relies on a feature), launch_resolve, list_templates, and
export_template(kind) (snapshot a live template project to templates/).
One MCP prompt, handoff, carries the recipe below. Every tool declares MCP
annotations (readOnlyHint / destructiveHint / idempotentHint /
openWorldHint).
Markers are tagged through Resolve's customData (oside:shot, oside:cue)
so the gate tells them apart by more than colour; a bare Blue marker from a
pre-0.2 timeline still counts as a shot marker.
pipeline.py <manifest.json> [--name] [--timeline] [--dry-run] [--no-cues]
runs the whole thing as one command (what the studio's Build in Resolve
button spawns); --dry-run prints the plan and never launches Resolve. The
dry run is planned for the target project by name (--name, or the
manifest's project name): before that project exists, bins read unknown — not imported yet, wouldBuild is judged on disk and fps comes from the
kind's template — Resolve being open on some other project no longer turns
every file into missing where:"bin".
.venv/bin/python tests/test_handoff.py # no Resolve, no pytest needed
.venv/bin/python -m pytest tests # if pytest is installedDrives the dry run against tests/fixtures/pkg (a fixture package with empty
media files) and the whole build → verify path against a fake Resolve. The
fake reproduces two behaviours measured on Resolve 21.0.4.5: a video clip
with embedded audio also fills A1, and a clip-info append aimed at an
occupied frame answers a truthy [<PyRemoteObject>] while placing nothing.
OSIDE_RESOLVE_OFFLINE=1 (set by the test module) keeps the offline cases
offline even with a real Resolve running.
The quick start above is the short path. This is everything, including the look step and the Depth Converter hand-off, as the studio's Build in Resolve button runs it.
Studio: board → DaVinci → attach VO → Export package (to the working drive)
Claude: resolve_status() ← preconditions + capabilities
build_timeline("/Volumes/.../manifest.json", dry_run=True, project="MY FILM")
← the plan BEFORE anything exists:
bins "unknown — not imported yet",
wouldBuild on disk, fps from template
create_project("cinematic", "MY FILM") ← or explainer
import_package("/Volumes/.../manifest.json")
build_timeline("/Volumes/.../manifest.json") ← V1 + VO track + markers
(depthc look-compare --manifest /Volumes/.../manifest.json --emit-cdl) ← Depth Converter, when the manifest has a look
apply_look("/Volumes/.../manifest.json", verify=True) ← node-1 CDL per clip: starting balance,
key/temp/sat only — and READ BACK out of an
EDL+CDL export, not just SetCDL's own answer
save_project() ← THE VERIFICATION BOUNDARY: an errored append's
placements survive every in-session read and are
discarded by the save. Verify on the far side of it.
verify_import("/Volumes/.../manifest.json") ← overall PASS|FAIL, VO rows name their track
- Creates projects/timelines, never deletes or overwrites — an existing project or timeline name is a hard refusal.
- Template projects are read (exported), never modified.
- Renders are user-triggered in Resolve, not tool-triggered.
save_projectrefuses the defaultUntitled Project: the call cannot succeed there and headless it blocks forever rather than returning.- Manifest entries may not be absolute, may not resolve outside the package, and may not share a clip name (Resolve matches clips by basename alone).
- stdio transport only; nothing listens on the network.
Every message the server raises, and its fix. Messages are quoted as they
appear in the tool's error field.
| error | cause | fix |
|---|---|---|
DaVinciResolveScript module not found: … |
Resolve's scripting modules are not at /Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules — Resolve is not installed, or installed somewhere else |
install DaVinci Resolve Studio at the standard path (or fix RESOLVE_MODULES in resolve_api.py) |
Cannot reach DaVinci Resolve. Is it running, and is Preferences → System → General → 'External scripting using' set to Local? |
Resolve is closed, or scripting is set to None / Network, or this is the free edition (no external scripting) | open Resolve (launch_resolve does it), set External scripting = Local, and use Studio — the free edition never answers |
Resolve did not answer scripting within 60s of launch. |
Resolve is still starting (first launch, database migration, a modal dialog on screen) | dismiss any dialog, wait, call resolve_status again |
GetProjectManager returned nothing — Resolve may still be starting. |
scripting answered before the Project Manager was up | retry in a few seconds |
No project named 'X' in the current project library folder. |
export_template: the template project named in templates/templates.json is not in the current library folder — renamed, moved, or a different library is open |
open the right project library / folder in Resolve, or fix the name in templates.json |
ExportProject failed for 'X' → … |
Resolve refused to write the .drp (path not writable, project open elsewhere) |
check templates/ is writable; close the template project; retry |
Template file missing: …/templates/<kind>.drp |
no snapshot for that kind yet | run export_template(kind) once with the template project available, and commit the .drp |
A project named 'X' already exists — pick another name. |
the create-only guardrail: this server never overwrites a project | choose a new name (in the studio: rename in the field and build again); delete the old one yourself in Resolve if that is what you want |
ImportProject failed (… as 'X'). |
the .drp is corrupt or from a newer Resolve, or the name is not accepted |
re-snapshot the template with export_template; use a plain name |
Imported but could not open 'X'. |
the project imported but LoadProject failed — usually a modal in Resolve |
dismiss the dialog, then open the project by hand or rerun with a new name |
A timeline named 'X' already exists — pick another name. |
same guardrail, timeline level | pass a different timeline_name |
No project is open — run create_project first. |
import_package / build_timeline / verify_import need the target project open |
create_project first, or open it in Resolve |
AddTrack('audio') failed — could not create the VO track. / … the track count did not grow. |
Resolve refused to add the narration's own audio track to the new timeline | check the timeline is not locked / a modal is not open; retry with a new timeline_name |
Could not add VO clip 'X' to the timeline (track N): Resolve placed nothing at the pinned frame or at the track tail. |
the VO track was re-read after both attempts and the take is not there — this server never counts a placement it cannot see | check the VO clip opens in Resolve; retry with a new timeline_name |
Target project 'X' is not the open project (…). |
a real build_timeline(…, project=X) while some other project is open — the build is refused rather than laid into the wrong project |
open X (or create_project it), or drop project to build into the open one |
Clips not in the media pool (run import_package first): … |
the timeline build reads clips from the VIDEOS bin and they are not there | run import_package; a build_timeline(dry_run=True) shows exactly which are missing |
Package file missing on disk: … |
a file the manifest names is gone (drive not mounted, package moved) | mount the drive / restore the package; export again from the studio |
Resolve laid N clip(s) on V1 but M were sent — timeline 'X' is incomplete |
Resolve dropped a clip during the append (an unreadable or offline source is the usual cause). The build stops rather than stamping shot markers onto the wrong pictures and reporting success | check every file opens in Resolve, then rebuild with a new timeline_name |
Could not make 'X' the current timeline — refusing to build into whichever timeline is open instead |
Resolve refused the timeline switch. The build refuses rather than appending into whatever was current — a template project ships with timelines of its own | close any modal, retry with a new timeline_name |
Resolve imported fewer files than the package names (...) |
ImportMedia returned fewer items than paths given |
check the media opens in Resolve (codec, drive mounted), then re-run import_package |
Two package files share the clip name 'X' |
two manifest entries have the same basename — Resolve matches clips by name alone, so one would silently stand in for the other | re-export from the studio (its exporter numbers colliding names) |
Manifest entry 'X' is an absolute path / resolves outside the package directory |
a hand-built or malformed manifest points outside its own package | make every file relative to the manifest |
manifest.project must be an object with a name`` |
project is a string or a number — a hand edit, or a producer that is not the studio |
re-export the package; the studio writes project: {id, name, …} |
Unknown kind 'X' — templates.json knows … |
the manifest's kind has no template; before 2026-09-06 a dry run blessed it and only create_project refused |
use a listed kind, or add a template (export_template) and its templates.json row |
manifest.videos must be a list of entries (also audio) |
the key holds an object or a string instead of a list | videos and audio are lists of {file, bin, …} entries |
manifest.videos[i] must be an entry object / has no file`` |
an entry is a bare string, or lost its file key |
every entry is {file, bin, …} with a relative file |
manifest.cues must be the worklist's filename (also textTasks) |
the key holds a list or object instead of a CSV filename — before 2026-09-06 that dropped the whole worklist silently and the gate expected zero markers | point the key at the CSV the studio wrote beside the manifest |
manifest.look must be an object |
the look block is a string or a list — a hand edit | the studio writes look: {schema, hex, shares, targets, …}; drop the key or re-export |
manifest.look.schema 'X' is not oside-look/1 |
the package carries a newer look block than this MCP reads | update oside-resolve-mcp, or re-export from a studio build that writes v1 |
pipeline crashed before a report could be built: … |
an exception past the manifest load — the report still prints, with the Python error in error |
read the error; if it names a manifest key, it is a shape this table does not know yet — file it |
Refusing to save 'Untitled Project' |
save_project on the default project — it has no location, the call cannot succeed, and headless it blocks indefinitely |
create_project first; the pipeline always names its project |
Manifest not found / Not an oside-davinci/v1 manifest |
wrong path, or not a package this server understands | point at the package's manifest.json; compare resolve_status().capabilities.manifest |
See CONTRIBUTING.md. Two things worth knowing before you
open a PR: the ten tools are deliberately few (a fixed pipeline gets fixed
tools), and the ANTI-PATTERNS in CLAUDE.md are load-bearing — each was bought
with a live walk, and each looks wrong until you have hit it.
MIT — see LICENSE.
DaVinci Resolve is a trademark of Blackmagic Design Pty Ltd. This project is an independent tool and is not affiliated with, endorsed by, or supported by Blackmagic Design. It drives Resolve through the public scripting API that Resolve Studio exposes.