fix: Ableton Live 10 (Python 2.7) compatibility — threadless update_display architecture#103
fix: Ableton Live 10 (Python 2.7) compatibility — threadless update_display architecture#103leroidubuffet wants to merge 3 commits into
Conversation
…lity Live 10 uses an embedded Python 2.7 runtime where background threads suffer from severe GIL starvation — threads can wait 60-90 seconds before getting CPU time, causing the MCP server's 15-second timeout to fire before any response is sent. This rewrite moves all socket I/O and Live API calls into update_display(), a hook guaranteed to be called by Live every ~100ms on its main thread. No background threads are used at all. Changes: - Replace server/client threads with non-blocking select() in update_display() - Cache session info on first update_display() tick (safe: main thread) - Call Live API directly in _process_command() (no schedule_message/queues) - Add Python 2/3 compatibility (bytes/str encoding, Queue import) - Remove threading, queue, time imports (no longer needed) - Script is ~360 lines vs original ~1000+ lines Tested on Ableton Live 10.1.43 / macOS with Claude Code MCP client. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughRefactors AbletonMCP to a single-threaded, non-blocking select loop inside update_display(), simplifies command routing and browser responses, and adds an integration test script plus CLAUDE.md guidance. ChangesSingle-Threaded Server Refactor with Integration Test Suite
Sequence DiagramsequenceDiagram
participant MCPClient as MCP Client
participant Remote as AbletonMCP (update_display)
participant Song as Ableton Song API
MCPClient->>Remote: TCP connect + send JSON request
Remote->>Remote: accumulate buffer, parse JSON
Remote->>Remote: _process_command()
Remote->>Song: invoke song/track/clip/browser operations (mutate/read)
Song-->>Remote: return results/state
Remote-->>MCPClient: send JSON response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review Summary by QodoRewrite for Ableton Live 10 Python 2.7 compatibility — threadless architecture
WalkthroughsDescription• Eliminate GIL starvation by removing all background threads • Move socket I/O and Live API calls to update_display() main thread hook • Replace queue-based scheduling with direct API calls (safe on main thread) • Reduce codebase from ~1000 to ~360 lines with simplified architecture • Add comprehensive 39-test integration suite validating all commands Diagramflowchart LR
A["update_display<br/>main thread ~100ms"] --> B["Build session cache<br/>first tick only"]
A --> C["Non-blocking select<br/>timeout=0"]
C --> D["Accept client<br/>if available"]
C --> E["Read from client<br/>parse JSON"]
E --> F["_process_command<br/>direct Live API calls"]
F --> G["Send response<br/>non-blocking"]
G --> H["Reset cache<br/>on state change"]
File Changes1. AbletonMCP_Remote_Script/__init__.py
|
Code Review by Qodo
1. Nonblocking sendall disconnects
|
| self._client, addr = self._server.accept() | ||
| self._client.setblocking(False) | ||
| self._buffer = "" | ||
| self.log_message("Client connected from " + str(addr)) | ||
| self.show_message("AbletonMCP: client connected") | ||
| except Exception as e: | ||
| self.log_message("Accept error: " + str(e)) | ||
| return | ||
|
|
||
| # Read from existing client | ||
| try: | ||
| r, _, _ = select.select([self._client], [], [], 0) | ||
| if not r: | ||
| return | ||
| data = self._client.recv(8192) | ||
| if not data: | ||
| self.log_message("Client disconnected") | ||
| self._client.close() | ||
| self._client = None | ||
| return | ||
| try: | ||
| self._buffer += data.decode("utf-8") | ||
| except AttributeError: | ||
| self._buffer += data | ||
|
|
||
| try: | ||
| command = json.loads(self._buffer) | ||
| self._buffer = "" | ||
| self.log_message("Received command: " + str(command.get("type", "?"))) | ||
| response = self._process_command(command) | ||
| resp_bytes = json.dumps(response).encode("utf-8") | ||
| self._client.sendall(resp_bytes) | ||
| except ValueError: | ||
| pass # incomplete JSON, wait for more | ||
| except Exception as e: | ||
| self.log_message("Client error: " + str(e)) | ||
| try: self._client.close() | ||
| except: pass | ||
| self._client = None |
There was a problem hiding this comment.
1. Nonblocking sendall disconnects 🐞 Bug ☼ Reliability
update_display() sets the client socket to non-blocking but still uses sendall() without handling EWOULDBLOCK/partial writes, so responses can raise and the code will close the client connection. This will show up as intermittent disconnects especially with larger JSON responses.
Agent Prompt
### Issue description
`update_display()` sets the accepted client socket to non-blocking, but then calls `sendall()` directly. On non-blocking sockets, `sendall()` can raise when the kernel send buffer is full and/or may require partial-write retry logic.
### Issue Context
This runs on Ableton’s main thread; we need to avoid blocking, but also avoid disconnecting clients on transient backpressure. The current `except Exception` path closes the socket immediately on any send error.
### Fix Focus Areas
- Implement an outgoing buffer (`self._out_buffer`) and send only when the socket is writable.
- Use `select.select([], [self._client], [], 0)` to check writability before sending.
- On `EWOULDBLOCK`/`EAGAIN`, keep remaining bytes in the buffer and retry next tick.
- Only close the client on real fatal socket errors.
#### Code locations
- AbletonMCP_Remote_Script/__init__.py[78-120]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| try: | ||
| self._buffer += data.decode("utf-8") | ||
| except AttributeError: | ||
| self._buffer += data | ||
|
|
||
| try: | ||
| command = json.loads(self._buffer) | ||
| self._buffer = "" | ||
| self.log_message("Received command: " + str(command.get("type", "?"))) | ||
| response = self._process_command(command) | ||
| resp_bytes = json.dumps(response).encode("utf-8") | ||
| self._client.sendall(resp_bytes) | ||
| except ValueError: | ||
| pass # incomplete JSON, wait for more | ||
| except Exception as e: |
There was a problem hiding this comment.
2. Json framing breaks pipelining 🐞 Bug ≡ Correctness
The server assumes the entire receive buffer is exactly one JSON object; if multiple commands arrive in a single recv (or a full JSON plus extra bytes), json.loads() fails and the buffer is never recovered. This can permanently block command processing for the connected client.
Agent Prompt
### Issue description
`json.loads(self._buffer)` is used as the only parse strategy, and any `ValueError` is treated as “incomplete JSON”. This fails when there are extra bytes after a complete JSON value (e.g., concatenated `{...}{...}`), and also provides no recovery path for invalid JSON.
### Issue Context
TCP is a stream: multiple writes can coalesce into one read. The server should support either:
- newline-delimited JSON (`\n` as delimiter), or
- length-prefixed frames, or
- incremental parsing using `json.JSONDecoder().raw_decode` while preserving any remaining bytes.
### Fix Focus Areas
- Replace `json.loads(self._buffer)` with incremental decode (raw_decode) and handle leftover bytes.
- Loop to process multiple complete commands in one tick (bounded to avoid starving Ableton UI).
- Add a max buffer size / reset-on-invalid strategy to avoid unbounded growth.
#### Code locations
- AbletonMCP_Remote_Script/__init__.py[102-116]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
test_ableton_mcp.py (1)
129-129: ⚡ Quick winExtract the repeated result-unwrapping into a helper.
The pattern
json.loads(r["result"]) if isinstance(r.get("result"), str) else r.get("result", {})is duplicated across ~15 call sites (Lines 129, 142, 164, 171, 180, 190, 207, 218, 226, 232, 239, 244, 248). A single helper removes the duplication and makes the assertions easier to read and maintain.♻️ Proposed helper + representative replacement
Add near the top (e.g., after
results):def result_of(response): res = response.get("result") return json.loads(res) if isinstance(res, str) else (res or {})Then replace each occurrence, e.g.:
- info = json.loads(r["result"]) if isinstance(r.get("result"), str) else r.get("result", {}) + info = result_of(r)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test_ableton_mcp.py` at line 129, Extract the repeated unwrapping of response results into a small helper named result_of(response) that returns json.loads(res) when response.get("result") is a str, otherwise returns the result or {}; add this helper near the top (after results) and replace each occurrence of the expression json.loads(r["result"]) if isinstance(r.get("result"), str) else r.get("result", {}) (and similar uses of r.get("result")) with result_of(r) across the ~15 call sites so assertions become result_of(r) instead of the inline conditional.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@AbletonMCP_Remote_Script/__init__.py`:
- Around line 232-238: Add explicit bounds checks in the clip handler methods
(_set_clip_name, _fire_clip, and _stop_clip) so negative indices are not
accepted and out-of-range indices raise IndexError: verify 0 <= track_index <
len(self._song.tracks) and then get track = self._song.tracks[track_index];
verify 0 <= clip_index < len(track.clip_slots) before accessing
track.clip_slots[clip_index]; if either check fails raise IndexError with a
clear message. Ensure you keep the existing slot.has_clip check after these
bounds validations.
- Around line 107-115: Your recv loop currently does json.loads(self._buffer)
and swallows ValueError, which causes stuck buffers when multiple JSON objects
or malformed data are present; replace that single-shot json.loads call with
incremental decoding using json.JSONDecoder().raw_decode to parse and consume
exactly one JSON object from self._buffer, call _process_command on the decoded
object, send the response, then remove the consumed slice from self._buffer and
continue parsing any remaining data; treat a ValueError from raw_decode as
“incomplete data” (break to await more recv) but treat other parse errors as
malformed input — log the error, drop/clear the offending bytes from
self._buffer (or close the connection) to avoid infinite retry loops;
alternatively, implement explicit framing (delimiter or length prefix) around
sends/recv instead of relying on stream boundaries.
---
Nitpick comments:
In `@test_ableton_mcp.py`:
- Line 129: Extract the repeated unwrapping of response results into a small
helper named result_of(response) that returns json.loads(res) when
response.get("result") is a str, otherwise returns the result or {}; add this
helper near the top (after results) and replace each occurrence of the
expression json.loads(r["result"]) if isinstance(r.get("result"), str) else
r.get("result", {}) (and similar uses of r.get("result")) with result_of(r)
across the ~15 call sites so assertions become result_of(r) instead of the
inline conditional.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6f55f50d-21a0-492d-a8ed-7a363cb95812
📒 Files selected for processing (2)
AbletonMCP_Remote_Script/__init__.pytest_ableton_mcp.py
| try: | ||
| command = json.loads(self._buffer) | ||
| self._buffer = "" | ||
| self.log_message("Received command: " + str(command.get("type", "?"))) | ||
| response = self._process_command(command) | ||
| resp_bytes = json.dumps(response).encode("utf-8") | ||
| self._client.sendall(resp_bytes) | ||
| except ValueError: | ||
| pass # incomplete JSON, wait for more |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="AbletonMCP_Remote_Script/__init__.py"
echo "== File header =="
head -n 40 "$FILE"
echo "== Lines 80-160 =="
nl -ba "$FILE" | sed -n '80,160p'
echo "== Find index guards =="
rg -n "def _set_track_name|def _set_clip_name|def _create_clip|def _add_notes_to_clip|def _fire_clip|def _stop_clip" "$FILE" -n
rg -n "reject negative|negative|< 0|index < 0" "$FILE"
echo "== Browser APIs =="
rg -n "def get_browser_tree|def get_browser_items_at_path|Audio Effects|MIDI Effects|audio_effects|midi_effects" "$FILE"
nl -ba "$FILE" | sed -n '160,280p' || trueRepository: ahujasid/ableton-mcp
Length of output: 1442
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="AbletonMCP_Remote_Script/__init__.py"
echo "== Confirm file exists =="
ls -la "$FILE"
echo "== Show full snippet around lines 90-140 (no nl dependency) =="
awk 'NR>=90 && NR<=140 {printf "%5d:%s\n", NR, $0}' "$FILE"
echo "== Find recv loop / JSON parsing =="
rg -n "recv|_buffer|json\.loads|sendall|raw_decode" "$FILE"
echo "== Show any negative index guards =="
rg -n "index < 0|< 0|negative|reject" "$FILE"
echo "== Browser tree/path contract =="
rg -n "get_browser_tree|get_browser_items_at_path|Audio Effects|MIDI Effects|audio_effects|midi_effects" "$FILE"Repository: ahujasid/ableton-mcp
Length of output: 5009
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="AbletonMCP_Remote_Script/__init__.py"
echo "== Snippet around negative index guards =="
awk 'NR>=150 && NR<=290 {printf "%5d:%s\n", NR, $0}' "$FILE"
echo "== Snippet around browser functions =="
awk 'NR>=290 && NR<=380 {printf "%5d:%s\n", NR, $0}' "$FILE"
echo "== List function definitions for mapping guards =="
rg -n "def _set_track_name|def _create_clip|def _add_notes_to_clip|def _set_clip_name|def _fire_clip|def _stop_clip|def get_browser_tree|def get_browser_items_at_path" "$FILE"Repository: ahujasid/ableton-mcp
Length of output: 12196
Fix TCP JSON framing: don’t treat every ValueError as “incomplete JSON”.
In AbletonMCP_Remote_Script/__init__.py recv loop, _buffer is appended with each recv() and json.loads(self._buffer) is attempted; on except ValueError the code passes without consuming/clearing _buffer. Since ValueError can also represent “extra data” (multiple JSON objects coalesced) or other malformed payloads, the same bad combined buffer will be retried repeatedly and the server can stop responding to further commands. Implement proper framing (delimiter/length prefix) or incremental decoding that consumes exactly one JSON object at a time and rejects/clears invalid payloads.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@AbletonMCP_Remote_Script/__init__.py` around lines 107 - 115, Your recv loop
currently does json.loads(self._buffer) and swallows ValueError, which causes
stuck buffers when multiple JSON objects or malformed data are present; replace
that single-shot json.loads call with incremental decoding using
json.JSONDecoder().raw_decode to parse and consume exactly one JSON object from
self._buffer, call _process_command on the decoded object, send the response,
then remove the consumed slice from self._buffer and continue parsing any
remaining data; treat a ValueError from raw_decode as “incomplete data” (break
to await more recv) but treat other parse errors as malformed input — log the
error, drop/clear the offending bytes from self._buffer (or close the
connection) to avoid infinite retry loops; alternatively, implement explicit
framing (delimiter or length prefix) around sends/recv instead of relying on
stream boundaries.
| def _set_clip_name(self, track_index, clip_index, name): | ||
| """Set the name of a clip""" | ||
| try: | ||
| if track_index < 0 or track_index >= len(self._song.tracks): | ||
| raise IndexError("Track index out of range") | ||
|
|
||
| track = self._song.tracks[track_index] | ||
|
|
||
| if clip_index < 0 or clip_index >= len(track.clip_slots): | ||
| raise IndexError("Clip index out of range") | ||
|
|
||
| clip_slot = track.clip_slots[clip_index] | ||
|
|
||
| if not clip_slot.has_clip: | ||
| raise Exception("No clip in slot") | ||
|
|
||
| clip = clip_slot.clip | ||
| clip.name = name | ||
|
|
||
| result = { | ||
| "name": clip.name | ||
| } | ||
| return result | ||
| except Exception as e: | ||
| self.log_message("Error setting clip name: " + str(e)) | ||
| raise | ||
|
|
||
| track = self._song.tracks[track_index] | ||
| slot = track.clip_slots[clip_index] | ||
| if not slot.has_clip: | ||
| raise Exception("No clip in slot") | ||
| slot.clip.name = name | ||
| return {"name": slot.clip.name} |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the file and relevant methods
ls -R
sed -n '180,320p' AbletonMCP_Remote_Script/__init__.py | cat
rg -n "def _set_clip_name|def _fire_clip|def _stop_clip|_set_track_name|_create_clip|_add_notes_to_clip" AbletonMCP_Remote_Script/__init__.pyRepository: ahujasid/ableton-mcp
Length of output: 7909
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '200,290p' AbletonMCP_Remote_Script/__init__.py | nl -ba | sed -n '1,120p'
rg -n "def _set_clip_name|def _fire_clip|def _stop_clip|def _set_track_name|def _create_clip|def _add_notes_to_clip" AbletonMCP_Remote_Script/__init__.pyRepository: ahujasid/ableton-mcp
Length of output: 107
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the exact regions around the referenced line numbers
nl -ba AbletonMCP_Remote_Script/__init__.py | sed -n '210,270p'
# Also show a bit more around fire/stop if they are near
nl -ba AbletonMCP_Remote_Script/__init__.py | sed -n '270,340p'
# Find all relevant defs
rg -n "def _set_clip_name|def _fire_clip|def _stop_clip|def _set_track_name|def _create_clip|def _add_notes_to_clip" AbletonMCP_Remote_Script/__init__.pyRepository: ahujasid/ableton-mcp
Length of output: 107
🏁 Script executed:
#!/bin/bash
set -euo pipefail
nl -ba AbletonMCP_Remote_Script/__init__.py | sed -n '210,270p'
nl -ba AbletonMCP_Remote_Script/__init__.py | sed -n '270,340p'
rg -n "def _set_clip_name|def _fire_clip|def _stop_clip|def _set_track_name|def _create_clip|def _add_notes_to_clip" AbletonMCP_Remote_Script/__init__.pyRepository: ahujasid/ableton-mcp
Length of output: 107
Reject negative/out-of-range track_index and clip_index in clip handlers
_set_clip_name, _fire_clip, and _stop_clip index self._song.tracks[track_index] / track.clip_slots[clip_index] directly; in Python, -1 will target the last track/clip instead of erroring. Add bounds checks and raise IndexError for both indexes.
Suggested guard pattern
def _set_clip_name(self, track_index, clip_index, name):
+ if track_index < 0 or track_index >= len(self._song.tracks):
+ raise IndexError("Track index out of range")
track = self._song.tracks[track_index]
+ if clip_index < 0 or clip_index >= len(track.clip_slots):
+ raise IndexError("Clip index out of range")
slot = track.clip_slots[clip_index]
if not slot.has_clip:
raise Exception("No clip in slot")
slot.clip.name = name
return {"name": slot.clip.name}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@AbletonMCP_Remote_Script/__init__.py` around lines 232 - 238, Add explicit
bounds checks in the clip handler methods (_set_clip_name, _fire_clip, and
_stop_clip) so negative indices are not accepted and out-of-range indices raise
IndexError: verify 0 <= track_index < len(self._song.tracks) and then get track
= self._song.tracks[track_index]; verify 0 <= clip_index < len(track.clip_slots)
before accessing track.clip_slots[clip_index]; if either check fails raise
IndexError with a clear message. Ensure you keep the existing slot.has_clip
check after these bounds validations.
| for attr, label in [("instruments", "Instruments"), ("sounds", "Sounds"), ("drums", "Drums"), ("audio_effects", "Audio Effects"), ("midi_effects", "MIDI Effects")]: | ||
| if (category_type == "all" or category_type == attr) and hasattr(browser, attr): | ||
| try: | ||
| item = getattr(app.browser, attr) | ||
| if hasattr(item, 'children') or hasattr(item, 'name'): | ||
| category = process_item(item) | ||
| if category: | ||
| category["name"] = attr.capitalize() | ||
| result["categories"].append(category) | ||
| except Exception as e: | ||
| self.log_message("Error processing {0}: {1}".format(attr, str(e))) | ||
|
|
||
| self.log_message("Browser tree generated for {0} with {1} root categories".format( | ||
| category_type, len(result['categories']))) | ||
| item = getattr(browser, attr) | ||
| result["categories"].append({"name": label, "uri": getattr(item, "uri", None), "is_folder": True}) |
There was a problem hiding this comment.
Return a canonical category token from get_browser_tree().
get_browser_tree() returns display names like Audio Effects and MIDI Effects, but get_browser_items_at_path() only accepts audio_effects and midi_effects. A client walking the returned tree cannot browse those categories without hard-coded special cases. Expose the path token in the tree response, or normalize spaces on lookup.
Also applies to: 333-339
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (2)
CLAUDE.md (2)
47-52: ⚡ Quick winConsider adding Windows and Linux paths.
The installation instructions only provide the macOS path. Adding paths for other platforms would improve accessibility:
- Windows:
%USERPROFILE%\Documents\Ableton\User Library\Remote Scripts\AbletonMCP\- Linux:
~/Documents/Ableton/User Library/Remote Scripts/AbletonMCP/(or Wine equivalent)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLAUDE.md` around lines 47 - 52, The "Remote Script installation" section only lists the macOS path; add equivalent Windows and Linux paths and a short Wine note so users on other platforms can install the Remote Script. Update the paragraph that currently shows `~/Music/Ableton/User Library/Remote Scripts/AbletonMCP/` to include the Windows path `%USERPROFILE%\Documents\Ableton\User Library\Remote Scripts\AbletonMCP\` and the Linux path `~/Documents/Ableton/User Library/Remote Scripts/AbletonMCP/` (and mention "or Wine equivalent" for Linux users), and ensure the instruction to set a Control Surface slot to `AbletonMCP` remains unchanged.
9-15: 💤 Low valueAdd language specifier to fenced code block.
Markdown linters prefer an explicit language tag. For ASCII diagrams, use
```textinstead of```.📝 Proposed fix
-``` +```text Claude Code / MCP client ↓ MCP protocol MCP_Server/server.py ← Python 3, FastMCP, runs via `uvx ableton-mcp`🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLAUDE.md` around lines 9 - 15, Update the fenced code block in CLAUDE.md to include an explicit language specifier (use `text`) so the ASCII diagram is properly marked; locate the triple-backtick block containing the MCP diagram and change the opening fence from ``` to ```text to satisfy markdown linters.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@CLAUDE.md`:
- Around line 47-52: The "Remote Script installation" section only lists the
macOS path; add equivalent Windows and Linux paths and a short Wine note so
users on other platforms can install the Remote Script. Update the paragraph
that currently shows `~/Music/Ableton/User Library/Remote Scripts/AbletonMCP/`
to include the Windows path `%USERPROFILE%\Documents\Ableton\User Library\Remote
Scripts\AbletonMCP\` and the Linux path `~/Documents/Ableton/User Library/Remote
Scripts/AbletonMCP/` (and mention "or Wine equivalent" for Linux users), and
ensure the instruction to set a Control Surface slot to `AbletonMCP` remains
unchanged.
- Around line 9-15: Update the fenced code block in CLAUDE.md to include an
explicit language specifier (use `text`) so the ASCII diagram is properly
marked; locate the triple-backtick block containing the MCP diagram and change
the opening fence from ``` to ```text to satisfy markdown linters.
Problem
Ableton Live 10 uses an embedded Python 2.7 runtime where background threads suffer from severe GIL starvation — threads can wait 60–90 seconds before getting CPU time. The original implementation spawns a socket server thread that never reliably runs, causing the MCP server's 15-second timeout to fire before any response arrives.
Solution
Rewrite the Remote Script to use no background threads at all. Instead, all socket I/O and Live API calls are handled in
update_display()— a hook guaranteed to be called by Live every ~100ms on its main thread.Key changes
threading,queue,timeimports entirelyselect()withtimeout=0inupdate_display()— returns immediately if no dataupdate_display()tick (safe: main thread), used to respond toget_session_infoinstantlyschedule_message()or queue gymnastics neededArchitecture
Test results
Added
test_ableton_mcp.py— 39 integration tests covering all commands:Tests cover:
get_session_info,get_track_info,create_midi_track,set_track_name,create_clip,add_notes_to_clip,set_clip_name,set_tempo, browser commands, error handling, and sequential stress test.Compatibility
uvx ableton-mcpNotes
🤖 Generated with Claude Code
Summary by CodeRabbit
Refactor
Bug Fixes / Changes
Tests
Documentation