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.
Files changed (29) hide show
  1. mcp_server_ableton_live-1.0.1/LICENSE +22 -0
  2. mcp_server_ableton_live-1.0.1/MCP_Server/__init__.py +5 -0
  3. mcp_server_ableton_live-1.0.1/MCP_Server/app.py +57 -0
  4. mcp_server_ableton_live-1.0.1/MCP_Server/cli.py +132 -0
  5. mcp_server_ableton_live-1.0.1/MCP_Server/connection.py +237 -0
  6. mcp_server_ableton_live-1.0.1/MCP_Server/server.py +39 -0
  7. mcp_server_ableton_live-1.0.1/MCP_Server/tools/__init__.py +29 -0
  8. mcp_server_ableton_live-1.0.1/MCP_Server/tools/_util.py +6 -0
  9. mcp_server_ableton_live-1.0.1/MCP_Server/tools/arrangement.py +144 -0
  10. mcp_server_ableton_live-1.0.1/MCP_Server/tools/browser.py +185 -0
  11. mcp_server_ableton_live-1.0.1/MCP_Server/tools/clips.py +327 -0
  12. mcp_server_ableton_live-1.0.1/MCP_Server/tools/devices.py +228 -0
  13. mcp_server_ableton_live-1.0.1/MCP_Server/tools/generators.py +428 -0
  14. mcp_server_ableton_live-1.0.1/MCP_Server/tools/prompts.py +41 -0
  15. mcp_server_ableton_live-1.0.1/MCP_Server/tools/session.py +282 -0
  16. mcp_server_ableton_live-1.0.1/MCP_Server/tools/tracks.py +223 -0
  17. mcp_server_ableton_live-1.0.1/PKG-INFO +169 -0
  18. mcp_server_ableton_live-1.0.1/README.md +150 -0
  19. mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/PKG-INFO +169 -0
  20. mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/SOURCES.txt +27 -0
  21. mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/dependency_links.txt +1 -0
  22. mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/entry_points.txt +3 -0
  23. mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/requires.txt +5 -0
  24. mcp_server_ableton_live-1.0.1/mcp_server_ableton_live.egg-info/top_level.txt +1 -0
  25. mcp_server_ableton_live-1.0.1/pyproject.toml +53 -0
  26. mcp_server_ableton_live-1.0.1/setup.cfg +4 -0
  27. mcp_server_ableton_live-1.0.1/tests/test_connection.py +136 -0
  28. mcp_server_ableton_live-1.0.1/tests/test_dispatch.py +91 -0
  29. 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,5 @@
1
+ """Ableton Live integration through the Model Context Protocol."""
2
+
3
+ __version__ = "1.0.1"
4
+
5
+ from .connection import AbletonConnection, get_ableton_connection # noqa: F401
@@ -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,6 @@
1
+ """Shared helpers for tool modules."""
2
+
3
+
4
+ def params(**kw):
5
+ """Build a wire-params dict, dropping None values (omitted optionals)."""
6
+ return {k: v for k, v in kw.items() if v is not None}
@@ -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')}"