The problem
AbletonMCP is an open-source bridge that lets an AI agent drive a live Ableton session over the Model Context Protocol. But it could only author MIDI — add_notes_to_clip was the single note-path tool, and it was write-only and purely additive. There was no way to read what was in a clip, and no way to replace its contents.
So “iterate on this loop” forced the model into a bad place: hold the entire clip in context, regenerate it from memory, and append. It was blind to any edit you’d made by hand in Live, had no way to verify what actually landed, and couldn’t replace anything. The agent was composing with its eyes closed.
I’d already spent time inside this codebase getting it to run reliably (more on that below), so I knew where the gap was. The fix was a feature contribution: give the agent the ability to read a clip and to clear it.
Architecture & why
First, ownership. The framework — add_notes_to_clip, the threading/dispatch architecture, the socket protocol, the telemetry decorator — is Siddharth Ahuja’s (ahujasid/ableton-mcp, MIT). My contribution is the two new tools, their tests, and the docs, following his existing patterns. Naming what isn’t mine is the point: the judgment is in how the two tools were added, not in the surrounding machinery.
flowchart LR A["AI agent"] --> B["get_notes_from_clip<br/>read-only · off main thread"] B --> C["edit the note list<br/>in context"] C --> D["clear_notes_from_clip<br/>mutating · main-thread scheduled"] D --> E["add_notes_to_clip<br/>(upstream)"] E --> F["clip now holds ONLY<br/>the edited notes"]
The clear step is the whole game: it’s what turns upstream’s additive “add” into a genuine read → modify → write loop, instead of an ever-growing pile of appended notes.
- Decision: triage the gap by layer —
[SERVER]vs[API-CEILING]vs[ECOSYSTEM]— and verify the Live Object Model surface before writing code. Alternative rejected: take the README’s “can’t read notes” at face value and treat it as an Ableton limitation. Why: the LOM already exposesget_notes_extended/remove_notes_extended. This wasn’t an API ceiling — it was an unwrapped server gap, the kind of thing that’s trivial to fix once you stop assuming the platform is the blocker. The same layered triage correctly kept a genuine ceiling (no native fill-range call) out of scope. Knowing which gaps are real is the judgment. - Decision: two deliberate thread models — read-only
get_notes_from_clipruns off the main thread; mutatingclear_notes_from_clipis main-thread-scheduled and registered in all three dispatch sites. Alternative rejected: route everything through the main thread uniformly (simpler to reason about), or skip the registration. Why: it matches the codebase’s existing read/write split, and a mutating command that isn’t main-thread-registered silently races the audio engine — a footgun this codebase doesn’t surface, so it had to be wired into the main-thread list, the dispatch block, and the modifying-command set on the server side. - Decision: call the modern
*_extendedLOM API with a legacy fallback, and document the pitch-first argument order in code. Alternative rejected: use only legacyget_notes/remove_notes. Why:*_extendedis note-id-aware (Live 11+) and future-proof, the fallback keeps older Live working — andget_notes_extended(from_pitch, pitch_span, …)inverts the legacy time-first order. That’s a trivial-to-invert bug waiting to happen, so it’s flagged in a comment rather than left as a trap. - Decision: return read output in the exact dict shape
add_notes_to_clipalready consumes (pitch / start / duration / velocity / mute), with richer LOM fields riding along harmlessly. Alternative rejected: a bespoke, richer read schema. Why: it makes read → edit → clear → write a closed loop with no translation layer — the output of one tool is valid input to the next. The full round-trip is proven bytest_true_replace_loop_read_clear_add. - Scope decision: ship small and finished — note read/clear only — and defer the drum-rack / per-pad cluster. Alternative rejected: build the flashier drum-rack sample-loading feature instead. Why: the gap analysis judged drum-rack an execution play, not a moat — reachable through Live’s standard browser-and-selection API, and a standing maintenance cost against Live’s version churn. A small, correct, finished contribution is more useful upstream than a big speculative one.
Evals / validation
- A 13-test pytest suite (
test_get_notes_from_clip.py), fully hermetic — the Ableton socket is mocked via afake_connfixture and a telemetry-noop fixture, so it runs with no DAW and no network. Coverage by intent: command/param correctness for both tools, output formatting, empty-clip-is-zero-not-an-error, connection errors caught, graceful fallback on missing metadata, and the full read → clear → add replace loop. Result: 13 / 13 passing. - I caught and replaced a stale 9-test version (which had no coverage for
clear_notes_from_clip) with the 13-test canonical suite before shipping, so both new tools are actually exercised — not just the one I started with. - A 5-step manual integration procedure (
INTEGRATION_TEST.md) against a real Set, which pre-documents the expected LOM quirks (notes reorder on read, ints come back as floats, extra per-note fields appear) as non-failures, plus both error guards (No clip in slot,not a MIDI clip). Verified on Live 12.4.2 / macOS — steps 1–5 pass, notes round-trip exactly. - Zero regression surface: the diff is purely additive (+612 / −0); no existing tool or behavior changed.
Outcome
- Before: one note-path tool (
add_notes_to_clip), write-only and additive — no read-back, no replace. - After: two new MCP tools close the loop. read → modify → clear → write is now a genuine replace, and the agent can verify, and build on, edits you made by hand in Live.
- Submitted upstream as PR #106 — +612 / −0 across 6 files, 13 / 13 unit tests green, integration verified on Live 12.4.2. Runnable as a fork today.
Honesty note. PR #106 is open, not merged — it’s a proposed contribution, not an accepted one. Integration was verified on a single platform (Live 12.4.2 / macOS); the unit tests stop at the socket boundary, so they prove the server logic, not the LOM itself — that half rests on the one-machine integration run. The deferred drum-rack work includes one claim (programmatic drum-pad selection from a headless Remote Script) I’ve explicitly flagged as unverified; I’m not presenting it as proven.
Before the feature: getting the bridge to run reliably
The reason I knew where the note-path gap was is that I’d already done the unglamorous work of making this bridge install and run the same way every time — and debugging it when it didn’t.
- One-command install instead of a 7-step guide. The setup scripts merge into Claude’s MCP config with
jqand a backup (rather than overwriting it and wiping your other servers), hard-code the full path touvx, and check the file is writable first. That last detail heads off a real macOS footgun: launched from the GUI, the app gets a stripped-downPATHand silently can’t finduvx. - A log-driven root-cause diagnosis. A script reads — never modifies — Ableton’s
Log.txtto confirm which Remote Scripts actually loaded. That’s what pinpointed why the control surface wouldn’t load: the User Library had been moved to an external drive, and Live 12 only loads Remote Scripts from<User Library>/Remote Scripts/, not the older default paths.
It’s integration and debugging, and I’ll name it as that. But it’s also how I earned the context to make a clean feature contribution on top.
How this was built
This was built as an agentic pair via Claude Code (plan mode, headless CLI); the contribution commit lists Claude Opus 4.8 as co-author. The MCP tooling itself — designing and wiring the two tools across the server and Remote Script — is the deliverable. The LOM facts behind the design synthesize Ableton’s published Live Object Model reference and the Max-for-Live API stub; that’s research, not original API.