mcp-server-ableton-live 1.0.1__tar.gz
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.
- mcp_server_ableton_live-1.0.1/LICENSE +22 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/__init__.py +5 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/app.py +57 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/cli.py +132 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/connection.py +237 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/server.py +39 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/__init__.py +29 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/_util.py +6 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/arrangement.py +144 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/browser.py +185 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/clips.py +327 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/devices.py +228 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/generators.py +428 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/prompts.py +41 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/session.py +282 -0
- mcp_server_ableton_live-1.0.1/MCP_Server/tools/tracks.py +223 -0
- mcp_server_ableton_live-1.0.1/PKG-INFO +169 -0
- mcp_server_ableton_live-1.0.1/README.md +150 -0
- mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/PKG-INFO +169 -0
- mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/SOURCES.txt +27 -0
- mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/dependency_links.txt +1 -0
- mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/entry_points.txt +3 -0
- mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/requires.txt +5 -0
- mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/top_level.txt +1 -0
- mcp_server_ableton_live-1.0.1/pyproject.toml +53 -0
- mcp_server_ableton_live-1.0.1/setup.cfg +4 -0
- mcp_server_ableton_live-1.0.1/tests/test_connection.py +136 -0
- mcp_server_ableton_live-1.0.1/tests/test_dispatch.py +91 -0
- mcp_server_ableton_live-1.0.1/tests/test_server_tools.py +20 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Siddharth Ahuja
|
|
4
|
+
Copyright (c) 2026 wstierhout
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""FastMCP application instance and server lifecycle."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from .connection import disconnect_ableton, get_ableton_connection
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("AbletonMCPServer")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
17
|
+
"""Connect to Ableton on startup (best effort) and clean up on shutdown."""
|
|
18
|
+
try:
|
|
19
|
+
logger.info("AbletonMCP server starting up")
|
|
20
|
+
try:
|
|
21
|
+
get_ableton_connection()
|
|
22
|
+
logger.info("Successfully connected to Ableton on startup")
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.warning(f"Could not connect to Ableton on startup: {str(e)}")
|
|
25
|
+
logger.warning("Make sure the Ableton Remote Script is running")
|
|
26
|
+
yield {}
|
|
27
|
+
finally:
|
|
28
|
+
disconnect_ableton()
|
|
29
|
+
logger.info("AbletonMCP server shut down")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
INSTRUCTIONS = """Control Ableton Live via a Remote Script socket. Conventions:
|
|
33
|
+
|
|
34
|
+
- ALL indices are 0-based: track_index, clip_index (session slot), scene_index,
|
|
35
|
+
return_index (0 = Return A), device_index (position in the track's chain).
|
|
36
|
+
- Times, lengths, and positions are in BEATS (quarter notes), floats allowed.
|
|
37
|
+
- Volume faders are Live's normalized 0.0-1.0 range where 0.85 = 0 dB unity.
|
|
38
|
+
Device parameters use each parameter's native range - call get_device_parameters
|
|
39
|
+
first and read min/max/display before setting values.
|
|
40
|
+
- MIDI notes: pitch 0-127 (60 = C3 in Live's naming), velocity 1-127,
|
|
41
|
+
start_time/duration in beats relative to clip start.
|
|
42
|
+
- Typical workflow: create_midi_track -> load_instrument_or_effect (or
|
|
43
|
+
search_browser + load) -> create_clip -> add_notes_to_clip -> fire_clip /
|
|
44
|
+
duplicate_to_arrangement. Check get_session_info / get_track_info first.
|
|
45
|
+
- add_notes_to_clip REPLACES the clip's entire note content (use edit_notes for
|
|
46
|
+
incremental changes). duplicate_to_arrangement OVERWRITES whatever overlaps the
|
|
47
|
+
destination range. delete_track / delete_arrangement_clip shift later indices.
|
|
48
|
+
- Transport tools report the PRE-command state (start_playback may answer
|
|
49
|
+
"playing: false"); confirm with get_session_info.
|
|
50
|
+
- Use batch_commands for multi-step edits: one round-trip, one undo step.
|
|
51
|
+
- If every command suddenly times out, a modal dialog is open in Live (e.g. the
|
|
52
|
+
trial nag) - it freezes the Remote Script until dismissed. Ask the user to
|
|
53
|
+
press Enter in Live, then retry.
|
|
54
|
+
- undo/redo are available; prefer batch_commands so one undo reverts a whole edit.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
mcp = FastMCP("AbletonMCP", lifespan=server_lifespan, instructions=INSTRUCTIONS)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Setup helpers exposed as subcommands: install, uninstall, doctor.
|
|
2
|
+
|
|
3
|
+
These make onboarding a one-liner instead of a manual file copy, and give users
|
|
4
|
+
a way to diagnose a broken setup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import platform
|
|
8
|
+
import socket
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .connection import ABLETON_HOST, ABLETON_PORT
|
|
14
|
+
|
|
15
|
+
SCRIPT_FOLDER = "AbletonMCP"
|
|
16
|
+
RAW_BASE = "https://raw.githubusercontent.com/wstierhout/ableton-live-mcp"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _remote_scripts_dir() -> Path:
|
|
20
|
+
home = Path.home()
|
|
21
|
+
if platform.system() == "Windows":
|
|
22
|
+
return home / "Documents" / "Ableton" / "User Library" / "Remote Scripts"
|
|
23
|
+
return home / "Music" / "Ableton" / "User Library" / "Remote Scripts"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fetch_remote_script() -> bytes:
|
|
27
|
+
# Pin to this package's exact version so the installed Remote Script always
|
|
28
|
+
# matches the server (no mutable-branch fallback).
|
|
29
|
+
url = f"{RAW_BASE}/v{__version__}/AbletonMCP_Remote_Script/__init__.py"
|
|
30
|
+
try:
|
|
31
|
+
with urllib.request.urlopen(url, timeout=15) as resp:
|
|
32
|
+
if resp.status == 200:
|
|
33
|
+
return resp.read()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
f"Could not download the Remote Script for v{__version__} ({e}). "
|
|
37
|
+
"Download AbletonMCP_Remote_Script/__init__.py from the repo and copy it "
|
|
38
|
+
"into your User Library Remote Scripts/AbletonMCP/ folder by hand."
|
|
39
|
+
) from e
|
|
40
|
+
raise RuntimeError(f"Remote Script for v{__version__} not found at {url}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def install() -> int:
|
|
44
|
+
dest_dir = _remote_scripts_dir() / SCRIPT_FOLDER
|
|
45
|
+
parent = dest_dir.parent
|
|
46
|
+
if not parent.exists():
|
|
47
|
+
print(f"Could not find Ableton's Remote Scripts folder at:\n {parent}")
|
|
48
|
+
print(
|
|
49
|
+
"Is Ableton Live installed? If your User Library is elsewhere, copy the "
|
|
50
|
+
"Remote Script there by hand."
|
|
51
|
+
)
|
|
52
|
+
return 1
|
|
53
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
(dest_dir / "__init__.py").write_bytes(_fetch_remote_script())
|
|
55
|
+
print(f"Installed the Remote Script to:\n {dest_dir / '__init__.py'}\n")
|
|
56
|
+
print("Next:")
|
|
57
|
+
print(" 1. Restart Ableton Live.")
|
|
58
|
+
print(" 2. Settings > Link/Tempo/MIDI > Control Surface: AbletonMCP (Input/Output: None).")
|
|
59
|
+
print(" 3. Run `ableton-live-mcp doctor` to confirm the connection.")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def uninstall() -> int:
|
|
64
|
+
dest = _remote_scripts_dir() / SCRIPT_FOLDER / "__init__.py"
|
|
65
|
+
if dest.exists():
|
|
66
|
+
dest.unlink()
|
|
67
|
+
try:
|
|
68
|
+
dest.parent.rmdir()
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
print(f"Removed {dest}. Restart Live to unload it.")
|
|
72
|
+
else:
|
|
73
|
+
print("Nothing to remove; the Remote Script was not found.")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def doctor() -> int:
|
|
78
|
+
print(f"ableton-live-mcp {__version__} setup check\n")
|
|
79
|
+
ok = True
|
|
80
|
+
|
|
81
|
+
installed = (_remote_scripts_dir() / SCRIPT_FOLDER / "__init__.py").exists()
|
|
82
|
+
print(f" [{'ok' if installed else 'x '}] Remote Script installed in the User Library")
|
|
83
|
+
if not installed:
|
|
84
|
+
print(" fix: run `ableton-live-mcp install`")
|
|
85
|
+
ok = False
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
with socket.create_connection((ABLETON_HOST, ABLETON_PORT), timeout=3) as s:
|
|
89
|
+
s.sendall(b'{"type": "get_session_info", "params": {}}')
|
|
90
|
+
data = s.recv(8192)
|
|
91
|
+
reachable = b'"status"' in data
|
|
92
|
+
except Exception:
|
|
93
|
+
reachable = False
|
|
94
|
+
print(
|
|
95
|
+
f" [{'ok' if reachable else 'x '}] Remote Script reachable on "
|
|
96
|
+
f"{ABLETON_HOST}:{ABLETON_PORT}"
|
|
97
|
+
)
|
|
98
|
+
if not reachable:
|
|
99
|
+
print(" fix: start Ableton Live, select AbletonMCP as the Control Surface,")
|
|
100
|
+
print(" and dismiss any open dialog (the trial nag blocks the script).")
|
|
101
|
+
ok = False
|
|
102
|
+
|
|
103
|
+
print()
|
|
104
|
+
print("All good." if ok else "Some checks failed; follow the fixes above.")
|
|
105
|
+
return 0 if ok else 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
USAGE = """ableton-live-mcp - Ableton Live MCP server
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
ableton-live-mcp run the MCP server (this is what MCP clients call)
|
|
112
|
+
ableton-live-mcp install copy the Remote Script into Ableton's User Library
|
|
113
|
+
ableton-live-mcp uninstall remove the Remote Script
|
|
114
|
+
ableton-live-mcp doctor check that the setup is working
|
|
115
|
+
ableton-live-mcp --version print the version
|
|
116
|
+
ableton-live-mcp --help show this help
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run(argv) -> int:
|
|
121
|
+
cmd = argv[0]
|
|
122
|
+
if cmd in ("-h", "--help", "help"):
|
|
123
|
+
print(USAGE)
|
|
124
|
+
return 0
|
|
125
|
+
if cmd in ("-V", "--version", "version"):
|
|
126
|
+
print(__version__)
|
|
127
|
+
return 0
|
|
128
|
+
handlers = {"install": install, "uninstall": uninstall, "doctor": doctor}
|
|
129
|
+
if cmd not in handlers:
|
|
130
|
+
print(f"Unknown command: {cmd}\n\n{USAGE}")
|
|
131
|
+
return 2
|
|
132
|
+
return handlers[cmd]()
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Socket connection to the AbletonMCP Remote Script (port 9877)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
ABLETON_HOST = os.environ.get("ABLETON_HOST", "localhost")
|
|
13
|
+
ABLETON_PORT = int(os.environ.get("ABLETON_PORT", "9877"))
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("AbletonMCPServer")
|
|
16
|
+
|
|
17
|
+
# Client-side socket timeouts. The Remote Script enforces its own main-thread
|
|
18
|
+
# queue budget (10 s default; see _COMMAND_TIMEOUTS in the Remote Script for
|
|
19
|
+
# long-running overrides) - the client budget must exceed it or the client can
|
|
20
|
+
# drop the socket at the same instant the script replies. Keep these two maps
|
|
21
|
+
# in sync; SOCKET_HEADROOM covers transport latency on top of the script budget.
|
|
22
|
+
REMOTE_DEFAULT_TIMEOUT = 10.0
|
|
23
|
+
REMOTE_COMMAND_TIMEOUTS = {"create_audio_clip": 60.0}
|
|
24
|
+
SOCKET_HEADROOM = 5.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def command_timeout(command_type: str) -> float:
|
|
28
|
+
"""Client socket budget for one command: remote budget + transport headroom."""
|
|
29
|
+
return REMOTE_COMMAND_TIMEOUTS.get(command_type, REMOTE_DEFAULT_TIMEOUT) + SOCKET_HEADROOM
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AbletonConnection:
|
|
34
|
+
host: str
|
|
35
|
+
port: int
|
|
36
|
+
sock: socket.socket = None
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
# One request/response in flight at a time: concurrent MCP tool calls
|
|
40
|
+
# must not interleave frames on the shared socket.
|
|
41
|
+
self._io_lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def connect(self) -> bool:
|
|
44
|
+
"""Connect to the Ableton Remote Script socket server"""
|
|
45
|
+
if self.sock:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
50
|
+
self.sock.settimeout(5.0)
|
|
51
|
+
self.sock.connect((self.host, self.port))
|
|
52
|
+
self.sock.settimeout(None)
|
|
53
|
+
logger.info(f"Connected to Ableton at {self.host}:{self.port}")
|
|
54
|
+
return True
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Failed to connect to Ableton at {self.host}:{self.port}: {str(e)}")
|
|
57
|
+
self.sock = None
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def disconnect(self):
|
|
61
|
+
"""Disconnect from the Ableton Remote Script"""
|
|
62
|
+
if self.sock:
|
|
63
|
+
try:
|
|
64
|
+
self.sock.close()
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Error disconnecting from Ableton: {str(e)}")
|
|
67
|
+
finally:
|
|
68
|
+
self.sock = None
|
|
69
|
+
|
|
70
|
+
def receive_full_response(self, sock, timeout=15.0, buffer_size=8192):
|
|
71
|
+
"""Receive the complete response, potentially in multiple chunks"""
|
|
72
|
+
chunks = []
|
|
73
|
+
sock.settimeout(timeout)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
while True:
|
|
77
|
+
try:
|
|
78
|
+
chunk = sock.recv(buffer_size)
|
|
79
|
+
if not chunk:
|
|
80
|
+
if not chunks:
|
|
81
|
+
raise Exception("Connection closed before receiving any data")
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
chunks.append(chunk)
|
|
85
|
+
|
|
86
|
+
# Check if we've received a complete JSON object
|
|
87
|
+
try:
|
|
88
|
+
data = b"".join(chunks)
|
|
89
|
+
json.loads(data.decode("utf-8"))
|
|
90
|
+
logger.info(f"Received complete response ({len(data)} bytes)")
|
|
91
|
+
return data
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
# Incomplete JSON, continue receiving
|
|
94
|
+
continue
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
logger.warning("Socket timeout during chunked receive")
|
|
97
|
+
break
|
|
98
|
+
except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
|
|
99
|
+
logger.error(f"Socket connection error during receive: {str(e)}")
|
|
100
|
+
raise
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Error during receive: {str(e)}")
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
# If we get here, we either timed out or broke out of the loop
|
|
106
|
+
if chunks:
|
|
107
|
+
data = b"".join(chunks)
|
|
108
|
+
logger.info(f"Returning data after receive completion ({len(data)} bytes)")
|
|
109
|
+
try:
|
|
110
|
+
json.loads(data.decode("utf-8"))
|
|
111
|
+
return data
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
raise Exception("Incomplete JSON response received")
|
|
114
|
+
else:
|
|
115
|
+
raise Exception("No data received")
|
|
116
|
+
|
|
117
|
+
def send_command(self, command_type: str, params: dict[str, Any] = None) -> dict[str, Any]:
|
|
118
|
+
"""Send a command to Ableton and return the response"""
|
|
119
|
+
command = {"type": command_type, "params": params or {}}
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with self._io_lock:
|
|
123
|
+
if not self.sock and not self.connect():
|
|
124
|
+
raise ConnectionError(
|
|
125
|
+
"Not connected to Ableton. Start Live, select AbletonMCP as the "
|
|
126
|
+
"Control Surface (Settings > Link/Tempo/MIDI), and run `abletonmcp doctor`."
|
|
127
|
+
)
|
|
128
|
+
logger.info(f"Sending command: {command_type} with params: {params}")
|
|
129
|
+
self.sock.sendall(json.dumps(command).encode("utf-8"))
|
|
130
|
+
logger.info("Command sent, waiting for response...")
|
|
131
|
+
response_data = self.receive_full_response(
|
|
132
|
+
self.sock, timeout=command_timeout(command_type)
|
|
133
|
+
)
|
|
134
|
+
logger.info(f"Received {len(response_data)} bytes of data")
|
|
135
|
+
|
|
136
|
+
# Parse the response
|
|
137
|
+
response = json.loads(response_data.decode("utf-8"))
|
|
138
|
+
logger.info(f"Response parsed, status: {response.get('status', 'unknown')}")
|
|
139
|
+
|
|
140
|
+
if response.get("status") == "error":
|
|
141
|
+
logger.error(f"Ableton error: {response.get('message')}")
|
|
142
|
+
raise Exception(response.get("message", "Unknown error from Ableton"))
|
|
143
|
+
|
|
144
|
+
return response.get("result", {})
|
|
145
|
+
except TimeoutError:
|
|
146
|
+
logger.error("Socket timeout while waiting for response from Ableton")
|
|
147
|
+
self.sock = None
|
|
148
|
+
raise Exception(
|
|
149
|
+
"Timeout waiting for Ableton response. If ALL commands time out, a "
|
|
150
|
+
"modal dialog is likely open in Live (it freezes the Remote Script) - "
|
|
151
|
+
"ask the user to dismiss it (press Enter in Live), then retry."
|
|
152
|
+
)
|
|
153
|
+
except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
|
|
154
|
+
logger.error(f"Socket connection error: {str(e)}")
|
|
155
|
+
self.sock = None
|
|
156
|
+
raise Exception(
|
|
157
|
+
f"Connection to Ableton lost ({e}). Is Live still running? "
|
|
158
|
+
"Run `abletonmcp doctor` to diagnose."
|
|
159
|
+
)
|
|
160
|
+
except json.JSONDecodeError as e:
|
|
161
|
+
logger.error(f"Invalid JSON response from Ableton: {str(e)}")
|
|
162
|
+
if "response_data" in locals() and response_data:
|
|
163
|
+
logger.error(f"Raw response (first 200 bytes): {response_data[:200]}")
|
|
164
|
+
self.sock = None
|
|
165
|
+
raise Exception(f"Invalid response from Ableton: {str(e)}")
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"Error communicating with Ableton: {str(e)}")
|
|
168
|
+
self.sock = None
|
|
169
|
+
raise Exception(f"Communication error with Ableton: {str(e)}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Global connection singleton (shared across all tool modules)
|
|
173
|
+
_ableton_connection = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_ableton_connection():
|
|
177
|
+
"""Get or create a persistent Ableton connection"""
|
|
178
|
+
global _ableton_connection
|
|
179
|
+
|
|
180
|
+
if _ableton_connection is not None and _ableton_connection.sock is not None:
|
|
181
|
+
try:
|
|
182
|
+
# Check if the socket is still alive by peeking for data
|
|
183
|
+
# MSG_PEEK + MSG_DONTWAIT will raise BlockingIOError if alive but no data,
|
|
184
|
+
# or return b'' if the remote end has closed the connection.
|
|
185
|
+
_ableton_connection.sock.setblocking(False)
|
|
186
|
+
try:
|
|
187
|
+
data = _ableton_connection.sock.recv(1, socket.MSG_PEEK)
|
|
188
|
+
if data == b"":
|
|
189
|
+
raise ConnectionError("Remote end closed")
|
|
190
|
+
except BlockingIOError:
|
|
191
|
+
pass # Socket is alive, just no data waiting - this is normal
|
|
192
|
+
finally:
|
|
193
|
+
_ableton_connection.sock.setblocking(True)
|
|
194
|
+
return _ableton_connection
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.warning(f"Existing connection is no longer valid: {str(e)}")
|
|
197
|
+
try:
|
|
198
|
+
_ableton_connection.disconnect()
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
_ableton_connection = None
|
|
202
|
+
|
|
203
|
+
# Connection doesn't exist or is invalid, create a new one
|
|
204
|
+
max_attempts = 3
|
|
205
|
+
for attempt in range(1, max_attempts + 1):
|
|
206
|
+
try:
|
|
207
|
+
logger.info(
|
|
208
|
+
f"Connecting to Ableton at {ABLETON_HOST}:{ABLETON_PORT} "
|
|
209
|
+
f"(attempt {attempt}/{max_attempts})..."
|
|
210
|
+
)
|
|
211
|
+
_ableton_connection = AbletonConnection(host=ABLETON_HOST, port=ABLETON_PORT)
|
|
212
|
+
if _ableton_connection.connect():
|
|
213
|
+
logger.info("Created new persistent connection to Ableton")
|
|
214
|
+
return _ableton_connection
|
|
215
|
+
_ableton_connection = None
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Connection attempt {attempt} failed: {str(e)}")
|
|
218
|
+
if _ableton_connection:
|
|
219
|
+
_ableton_connection.disconnect()
|
|
220
|
+
_ableton_connection = None
|
|
221
|
+
if attempt < max_attempts:
|
|
222
|
+
time.sleep(1.0)
|
|
223
|
+
|
|
224
|
+
logger.error("Failed to connect to Ableton after multiple attempts")
|
|
225
|
+
raise Exception("Could not connect to Ableton. Make sure the Remote Script is running.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def disconnect_ableton():
|
|
229
|
+
"""Tear down the shared connection (used at server shutdown)."""
|
|
230
|
+
global _ableton_connection
|
|
231
|
+
if _ableton_connection:
|
|
232
|
+
logger.info("Disconnecting from Ableton on shutdown")
|
|
233
|
+
try:
|
|
234
|
+
_ableton_connection.disconnect()
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
_ableton_connection = None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""AbletonMCP server entrypoint.
|
|
2
|
+
|
|
3
|
+
Importing MCP_Server.tools registers every @mcp.tool with the FastMCP app.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from . import tools # noqa: F401 (tool registration side effect)
|
|
9
|
+
from .app import mcp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
"""Run the MCP server, or a setup subcommand (install/uninstall/doctor)."""
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
if len(sys.argv) > 1 and sys.argv[1] in (
|
|
17
|
+
"install",
|
|
18
|
+
"uninstall",
|
|
19
|
+
"doctor",
|
|
20
|
+
"--help",
|
|
21
|
+
"-h",
|
|
22
|
+
"help",
|
|
23
|
+
"--version",
|
|
24
|
+
"-V",
|
|
25
|
+
"version",
|
|
26
|
+
):
|
|
27
|
+
from . import cli
|
|
28
|
+
|
|
29
|
+
sys.exit(cli.run(sys.argv[1:]))
|
|
30
|
+
|
|
31
|
+
logging.basicConfig(
|
|
32
|
+
level=logging.INFO,
|
|
33
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
34
|
+
)
|
|
35
|
+
mcp.run()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Import selected tool modules so their @mcp.tool decorators register.
|
|
2
|
+
|
|
3
|
+
Set ABLETON_TOOLSETS to a comma-separated subset to load fewer tools (helps LLMs
|
|
4
|
+
pick the right tool when the full 104-tool surface is more than a task needs),
|
|
5
|
+
e.g. ABLETON_TOOLSETS="session,tracks,clips,generators". Default loads all.
|
|
6
|
+
Available: session, tracks, clips, devices, browser, arrangement, generators, prompts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
_ALL = ["session", "tracks", "clips", "devices", "browser", "arrangement", "generators", "prompts"]
|
|
13
|
+
|
|
14
|
+
_requested = os.environ.get("ABLETON_TOOLSETS", "").strip()
|
|
15
|
+
if _requested and _requested.lower() != "all":
|
|
16
|
+
_named = [m.strip() for m in _requested.split(",") if m.strip()]
|
|
17
|
+
_unknown = [m for m in _named if m not in _ALL]
|
|
18
|
+
if _unknown:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"Unknown ABLETON_TOOLSETS: {_unknown}. Valid groups: {[g for g in _ALL if g != 'prompts']}"
|
|
21
|
+
)
|
|
22
|
+
_selected = [m for m in _named if m in _ALL]
|
|
23
|
+
if "prompts" not in _selected:
|
|
24
|
+
_selected.append("prompts")
|
|
25
|
+
else:
|
|
26
|
+
_selected = _ALL
|
|
27
|
+
|
|
28
|
+
for _m in _selected:
|
|
29
|
+
importlib.import_module(f".{_m}", __name__)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Arrangement view, timeline placement, and clip automation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
from mcp.types import ToolAnnotations
|
|
7
|
+
|
|
8
|
+
from ..app import mcp
|
|
9
|
+
from ..connection import get_ableton_connection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=False))
|
|
13
|
+
def switch_to_arrangement_view(ctx: Context) -> str:
|
|
14
|
+
"""Switch Ableton's main window to the Arrangement view.
|
|
15
|
+
|
|
16
|
+
Parameters:
|
|
17
|
+
"""
|
|
18
|
+
ableton = get_ableton_connection()
|
|
19
|
+
ableton.send_command("switch_to_arrangement_view")
|
|
20
|
+
return "Switched to Arrangement view"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=False))
|
|
24
|
+
def set_arrangement_time(ctx: Context, time: float) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Move the arrangement playhead to a specific position.
|
|
27
|
+
|
|
28
|
+
Parameters:
|
|
29
|
+
- time: Position in beats from the start of the arrangement (e.g. 8.0 = bar 3 in 4/4)
|
|
30
|
+
"""
|
|
31
|
+
ableton = get_ableton_connection()
|
|
32
|
+
result = ableton.send_command("set_current_song_time", {"time": time})
|
|
33
|
+
return f"Playhead moved to beat {result.get('current_song_time', time)}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
37
|
+
def get_arrangement_clips(ctx: Context, track_index: int) -> str:
|
|
38
|
+
"""
|
|
39
|
+
List all clips placed in the Arrangement timeline for a track.
|
|
40
|
+
|
|
41
|
+
Returns each clip's name, start_time, end_time, length, and type.
|
|
42
|
+
|
|
43
|
+
Parameters:
|
|
44
|
+
- track_index: The index of the track to inspect
|
|
45
|
+
"""
|
|
46
|
+
ableton = get_ableton_connection()
|
|
47
|
+
result = ableton.send_command("get_arrangement_clips", {"track_index": track_index})
|
|
48
|
+
return json.dumps(result, indent=2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True))
|
|
52
|
+
def duplicate_to_arrangement(
|
|
53
|
+
ctx: Context, track_index: int, clip_index: int, destination_time: float
|
|
54
|
+
) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Copy a Session-view clip into the Arrangement timeline.
|
|
57
|
+
|
|
58
|
+
OVERWRITES whatever already occupies the destination range on that track
|
|
59
|
+
(like recording over tape) - this is also the supported way to REPLACE a
|
|
60
|
+
section. Uses Live's track.duplicate_clip_to_arrangement() API (Live 11/12).
|
|
61
|
+
The clip is placed at destination_time beats from the start of the
|
|
62
|
+
arrangement on the same track it lives in.
|
|
63
|
+
|
|
64
|
+
Typical workflow:
|
|
65
|
+
1. create_clip / add_notes_to_clip to build a Session clip
|
|
66
|
+
2. Call duplicate_to_arrangement once per bar/section you need
|
|
67
|
+
3. Call switch_to_arrangement_view to confirm the result in Live
|
|
68
|
+
|
|
69
|
+
Parameters:
|
|
70
|
+
- track_index: Index of the track that owns the Session clip
|
|
71
|
+
- clip_index: Index of the clip slot in that track (Session view)
|
|
72
|
+
- destination_time: Beat position in the arrangement to place the clip
|
|
73
|
+
(e.g. 0.0 = start, 8.0 = bar 3 in 4/4)
|
|
74
|
+
"""
|
|
75
|
+
ableton = get_ableton_connection()
|
|
76
|
+
result = ableton.send_command(
|
|
77
|
+
"duplicate_session_clip_to_arrangement",
|
|
78
|
+
{
|
|
79
|
+
"track_index": track_index,
|
|
80
|
+
"clip_index": clip_index,
|
|
81
|
+
"destination_time": destination_time,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
clip_name = result.get("clip_name", "clip")
|
|
85
|
+
track_name = result.get("track_name", f"track {track_index}")
|
|
86
|
+
return (
|
|
87
|
+
f"Duplicated '{clip_name}' from Session slot {clip_index} "
|
|
88
|
+
f"on '{track_name}' to arrangement at beat {destination_time}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True))
|
|
93
|
+
def delete_arrangement_clip(ctx: Context, track_index: int, arrangement_clip_index: int) -> str:
|
|
94
|
+
"""Delete a clip from the Arrangement timeline by its position in
|
|
95
|
+
get_arrangement_clips' list. NOTE: indices of later clips on the same track
|
|
96
|
+
shift down by one after each delete - re-read get_arrangement_clips between
|
|
97
|
+
deletes.
|
|
98
|
+
"""
|
|
99
|
+
result = get_ableton_connection().send_command(
|
|
100
|
+
"delete_arrangement_clip",
|
|
101
|
+
{"track_index": track_index, "arrangement_clip_index": arrangement_clip_index},
|
|
102
|
+
)
|
|
103
|
+
return f"Deleted arrangement clip '{result.get('name')}' ({result.get('start_time')}-{result.get('end_time')})"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=False))
|
|
107
|
+
def write_automation(
|
|
108
|
+
ctx: Context,
|
|
109
|
+
track_index: int,
|
|
110
|
+
clip_index: int,
|
|
111
|
+
device_index: int,
|
|
112
|
+
parameter: str | int,
|
|
113
|
+
points: list[dict[str, float]],
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Write clip automation for a device parameter. points = [{"time": beats, "value": v}, ...].
|
|
116
|
+
Replaces any existing envelope for that parameter on the clip."""
|
|
117
|
+
r = get_ableton_connection().send_command(
|
|
118
|
+
"write_automation",
|
|
119
|
+
{
|
|
120
|
+
"track_index": track_index,
|
|
121
|
+
"clip_index": clip_index,
|
|
122
|
+
"device_index": device_index,
|
|
123
|
+
"parameter": parameter,
|
|
124
|
+
"points": points,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
return f"Wrote {r.get('point_count')} automation points for {r.get('parameter')}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=False))
|
|
131
|
+
def clear_automation(
|
|
132
|
+
ctx: Context, track_index: int, clip_index: int, device_index: int, parameter: str | int
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Clear the clip automation envelope for a device parameter."""
|
|
135
|
+
r = get_ableton_connection().send_command(
|
|
136
|
+
"clear_automation",
|
|
137
|
+
{
|
|
138
|
+
"track_index": track_index,
|
|
139
|
+
"clip_index": clip_index,
|
|
140
|
+
"device_index": device_index,
|
|
141
|
+
"parameter": parameter,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
return f"Cleared automation for {r.get('parameter')}"
|