____ _ ____ ____ _ _ _____ __ __ ____ ____
/ ___| / \ | _ \ / ___| | | |_ _| | \/ |/ ___| _ \
| | / _ \ | |_) | | | | | | | | | |\/| | | | |_) |
| |___ / ___ \| __/| |___| |_| | | | | | | | |___| __/
\____/_/ \_\_| \____|\___/ |_| |_| |_|\____|_|
An MCP (Model Context Protocol) server for CapCut, written in Elixir. Lets Claude read and edit CapCut projects directly -- no CapCut API needed. Works by reading and writing CapCut's local JSON project files.
Built just for fun in a "crazy" language. Elixir/OTP with GenServers, supervision trees, pattern matching and pipes everywhere.
Claude gets 15 tools to work with your CapCut projects:
Read & Inspect
| Tool | What Claude can do |
|---|---|
list_projects |
Show all your CapCut drafts |
get_project |
Inspect canvas size, FPS, duration, track count |
get_timeline |
See all tracks, clips, and their timecodes |
read_draft_json |
Return the full raw project JSON for debugging |
Create & Add
| Tool | What Claude can do |
|---|---|
create_project |
Create a new empty draft (custom size/FPS) |
add_text |
Add a text overlay at a specific time |
add_clip |
Add a video or audio file to the timeline (validates file exists) |
Modify Clips
| Tool | What Claude can do |
|---|---|
set_clip_transform |
Position, scale, and rotate a clip |
set_clip_opacity |
Set clip transparency (0.0 -- 1.0) |
set_clip_volume |
Mute, normalize, or boost clip audio |
set_clip_loop |
Enable/disable clip looping |
set_clip_blend_mode |
Apply blend modes (Screen, Soft Light, Multiply, ...) from your local CapCut install |
move_clip |
Reposition a clip on the timeline |
trim_clip |
Set source in/out points and timeline duration |
remove_clip |
Remove a clip by its segment ID |
Example prompts once connected:
- "List my CapCut projects"
- "Add the text 'Intro' to project X at 0ms for 3 seconds"
- "Show me the timeline of my latest project"
- "Create a new 1080x1920 vertical project called 'Reel'"
- "Set the screen recording clip to Screen blend mode"
- "Move clip X to 5 seconds and set its opacity to 0.8"
- "Mute the video on track 2 and loop the mascot clip"
- Windows (CapCut stores projects in
%LOCALAPPDATA%\CapCut\) - Erlang/OTP 28 installed
- Elixir 1.19+ (see Installation)
- CapCut desktop app installed
1. Install Erlang OTP (if not already installed):
winget install Erlang.ErlangOTP2. Install Elixir (no winget package -- download the zip):
(New-Object Net.WebClient).DownloadFile(
'https://github.com/elixir-lang/elixir/releases/download/v1.19.5/elixir-otp-28.zip',
"$env:USERPROFILE\elixir.zip"
)
Expand-Archive "$env:USERPROFILE\elixir.zip" -DestinationPath "$env:USERPROFILE\elixir"
[Environment]::SetEnvironmentVariable(
'PATH',
[Environment]::GetEnvironmentVariable('PATH','User') + ";$env:USERPROFILE\elixir\bin",
'User'
)3. Clone and build:
git clone https://github.com/burnshall-ui/capcut-mcp.git
cd capcut-mcp
mix deps.get
mix compile4. Smoke test:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | mix run --no-haltExpected: a JSON response with "name":"capcut-mcp".
Create (or edit) .claude/settings.json in the project root:
{
"mcpServers": {
"capcut": {
"command": "mix",
"args": ["run", "--no-halt"],
"cwd": "C:/Users/<you>/Desktop/kram/capcut-mcp",
"env": {
"PATH": "C:\\Program Files\\Erlang OTP\\bin;C:\\Users\\<you>\\elixir\\bin"
}
}
}
}Restart Claude Code -- the capcut tools appear automatically.
Claude Desktop ignores the cwd config field on Windows, so mix run wouldn't find mix.exs. The fix is a small wrapper script that changes into the project directory first.
The repo ships a start-mcp.bat that does exactly that. Edit it to point at your Elixir installation if it differs from the default:
@echo off
cd /d "C:\Users\<you>\Desktop\kram\capcut-mcp"
"C:\Users\<you>\elixir\bin\mix.bat" run --no-haltThen add it to %APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"capcut": {
"command": "C:\\Users\\<you>\\Desktop\\kram\\capcut-mcp\\start-mcp.bat"
}
}
}Restart Claude Desktop.
CapcutMcp.CapCut.PathDiscovery auto-discovers the CapCut projects folder on boot. It checks, in order:
- The
CAPCUT_PATHenvironment variable %LOCALAPPDATA%\CapCut\User Data\Projects\com.lveditor.draft— the standard Windows install path
If neither resolves to an existing directory, the server still boots. The first tool call that needs disk access returns a descriptive error listing what was tried, so Claude can relay it back to you verbatim.
Override the discovered path (e.g. for a portable install) with CAPCUT_PATH:
CAPCUT_PATH="D:\CapCut\Projects\com.lveditor.draft" mix run --no-haltBlend mode discovery reads CapCut's local app resources from:
C:\Users\<you>\AppData\Local\CapCut\Apps
Usually you do not need to configure this manually on Windows because it is derived from %LOCALAPPDATA%.
If your CapCut installation lives elsewhere, override it with CAPCUT_APPS_PATH:
CAPCUT_APPS_PATH="D:\PortableApps\CapCut\Apps" mix run --no-haltClaude (stdin/stdout JSON-RPC 2.0)
└── MCP.Server GenServer -- stdin loop, dispatches messages
└── MCP.Dispatcher Pure -- routes tool calls by name
└── Tools.* Pure -- one module per tool (15 tools)
├── TimelineHelper Shared -- segment lookup, validation, UUID
├── BlendModes ETS-cached -- discovers CapCut MixMode resources
└── ProjectStore GenServer -- project cache + disk I/O
├── PathDiscovery Pure -- resolves projects root (env / LOCALAPPDATA)
├── Reader Pure -- reads JSON files
└── Writer Pure -- writes JSON files (atomic + backup)
OTP supervision tree:
CapcutMcp.Application
├── CapCut.ProjectStore (permanent)
└── MCP.Server (permanent)
If either process crashes, the supervisor restarts it automatically. The ProjectStore caches parsed project JSON in memory with read-through loading -- both reads and writes auto-populate the cache on miss, so a GenServer restart is transparent. BlendModes caches MixMode.json in an ETS table after the first read.
Every write to draft_content.json creates a .bak backup and uses an atomic rename (write .tmp -> rename) to prevent corruption if the process is killed mid-write.
Every tools/call goes through :telemetry.span/3 in CapcutMcp.MCP.Dispatcher, so duration and outcome are exposed as structured events — no custom logging plumbing in individual tools. The two core subsystems (ProjectStore cache and BlendModes loader) emit their own events too, and the boundary-hardening pass (Reader path-trust check) emits its own rejection event, so a single telemetry handler can answer "which tool was slow, was it cache-bound or disk-bound, and did any corrupt metadata get filtered on the way in?".
Emitted events:
| Event | Measurements | Metadata |
|---|---|---|
[:capcut_mcp, :tool, :execute, :start] |
:system_time, :monotonic_time |
:tool, :request_id |
[:capcut_mcp, :tool, :execute, :stop] |
:duration, :monotonic_time |
:tool, :request_id, :result, :reason? |
[:capcut_mcp, :tool, :execute, :exception] |
:duration, :monotonic_time |
:tool, :request_id, :kind, :reason, :stacktrace |
[:capcut_mcp, :cache, :hit] |
:count (always 1) |
:id |
[:capcut_mcp, :cache, :miss] |
:count (always 1) |
:id |
[:capcut_mcp, :cache, :write] |
:count (always 1) |
:id, :reason (:load | :update | :create) |
[:capcut_mcp, :blend_modes, :load] |
:duration |
:result (:ok | :error), :count?, :path?, :reason? |
[:capcut_mcp, :draft, :schema_version] |
:count (always 1) |
:version (String.t() | nil), :supported (boolean) |
[:capcut_mcp, :meta, :rejected] |
:count (always 1) |
:reason (:path_outside_root), :path (String.t()) |
A default log handler in CapcutMcp.Telemetry is attached on boot and prints one line per tool call, e.g.:
12:34:56.789 [info] tool=add_text request_id=42 result=ok duration=8.24ms
Logger.metadata is populated early in the pipeline (mcp_request_id, mcp_method, tool, request_id), so every log line further down the stack — including inside individual tools — is filterable by request.
[:capcut_mcp, :draft, :schema_version] fires on every successful read_draft and additionally triggers a Logger.warning when the draft's new_version field is missing or not in CapcutMcp.CapCut.Reader.supported_versions/0 — the server still reads the draft, it just flags that the on-disk schema is untested.
[:capcut_mcp, :meta, :rejected] fires when list_projects encounters a root_meta_info.json entry whose draft_fold_path resolves outside the configured CAPCUT_PATH. The entry is dropped from the response (so tool callers can't be tricked into reading or writing arbitrary files through a corrupt/malicious meta file), and a Logger.warning is emitted alongside the telemetry event.
The cache and blend-modes events are emitted but not logged by default (they're chatty on hit paths). Attach your own handler if you want a running hit-rate or a disk-load audit trail:
:telemetry.attach_many(
"my-cache-counter",
[
[:capcut_mcp, :cache, :hit],
[:capcut_mcp, :cache, :miss]
],
fn [_, _, kind], _measurements, _metadata, _cfg ->
:counters.add(my_counter_ref, kind_to_idx(kind), 1)
end,
nil
)To pipe events into Prometheus, OpenTelemetry, Datadog, etc., attach your own handler; the application code does not need to change.
# Run tests
mix test
# Code style (strict)
mix credo --strict
# Static analysis (first run builds PLT, ~1-2 min)
mix dialyzer
# Coverage (HTML report at cover/excoveralls.html)
mix coveralls # console summary
mix coveralls.html # full HTML report
# Format check
mix format --check-formatted
# Run with a custom CapCut path
CAPCUT_PATH="C:/path/to/projects" mix run --no-halt
# Interactive Elixir shell with the app running
iex -S mix run --no-halt- Export/render -- not possible via this server (CapCut has no CLI for export; only UI automation could do it)
- Cloud projects -- only local drafts are accessible
- Effects and templates -- can reference CapCut's built-in effect IDs but can't create new ones; the exact IDs vary by CapCut version
- CapCut format changes -- CapCut updates may change the JSON schema; tested against v8.3.0
- Elixir 1.19 / OTP 28 -- because why not
- Jason -- JSON encode/decode
- :telemetry -- structured events for every tool call
- Credo (
--strict) -- zero issues - Dialyzer (
:underspecs,:error_handling,:unknown) -- zero warnings - ExUnit -- 164 tests + 12
stream_dataproperty tests + 6 doctests (incl. JSON-RPC integration tests with telemetry assertions on tools + cache + blend-modes + schema-version + meta-rejected events, path-discovery fallbacks, a boundary fuzz test that hammers every registered tool with randomStreamData.term()arguments, and timeline-mutation invariants likeupdate_segmentroundtrip,ensure_timerangeidempotency andinsert_segmentcount preservation) - StreamData -- property-based tests for
TimelineHelper(UUID format, segment roundtrip, track insertion invariants,validate_timingdomain) and for thetools/callrequest boundary (no random payload may crash the dispatcher) - ExCoveralls -- ~88% line coverage on application code (remaining gaps are the stdin-loop I/O layer and a few lazy disk helpers)