livepilot 1.0.0

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 (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +409 -0
  4. package/bin/livepilot.js +390 -0
  5. package/installer/install.js +95 -0
  6. package/installer/paths.js +79 -0
  7. package/mcp_server/__init__.py +2 -0
  8. package/mcp_server/__main__.py +5 -0
  9. package/mcp_server/connection.py +210 -0
  10. package/mcp_server/memory/__init__.py +5 -0
  11. package/mcp_server/memory/technique_store.py +296 -0
  12. package/mcp_server/server.py +87 -0
  13. package/mcp_server/tools/__init__.py +1 -0
  14. package/mcp_server/tools/arrangement.py +407 -0
  15. package/mcp_server/tools/browser.py +86 -0
  16. package/mcp_server/tools/clips.py +218 -0
  17. package/mcp_server/tools/devices.py +256 -0
  18. package/mcp_server/tools/memory.py +198 -0
  19. package/mcp_server/tools/mixing.py +121 -0
  20. package/mcp_server/tools/notes.py +269 -0
  21. package/mcp_server/tools/scenes.py +89 -0
  22. package/mcp_server/tools/tracks.py +175 -0
  23. package/mcp_server/tools/transport.py +117 -0
  24. package/package.json +37 -0
  25. package/plugin/agents/livepilot-producer/AGENT.md +62 -0
  26. package/plugin/commands/beat.md +18 -0
  27. package/plugin/commands/memory.md +22 -0
  28. package/plugin/commands/mix.md +15 -0
  29. package/plugin/commands/session.md +13 -0
  30. package/plugin/commands/sounddesign.md +16 -0
  31. package/plugin/plugin.json +19 -0
  32. package/plugin/skills/livepilot-core/SKILL.md +208 -0
  33. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  34. package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
  35. package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
  36. package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
  37. package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
  38. package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
  39. package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
  40. package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
  41. package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
  42. package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
  43. package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
  44. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  45. package/plugin/skills/livepilot-core/references/memory-guide.md +107 -0
  46. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  47. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  48. package/plugin/skills/livepilot-core/references/overview.md +209 -0
  49. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  50. package/remote_script/LivePilot/__init__.py +42 -0
  51. package/remote_script/LivePilot/arrangement.py +693 -0
  52. package/remote_script/LivePilot/browser.py +424 -0
  53. package/remote_script/LivePilot/clips.py +211 -0
  54. package/remote_script/LivePilot/devices.py +596 -0
  55. package/remote_script/LivePilot/diagnostics.py +198 -0
  56. package/remote_script/LivePilot/mixing.py +194 -0
  57. package/remote_script/LivePilot/notes.py +339 -0
  58. package/remote_script/LivePilot/router.py +74 -0
  59. package/remote_script/LivePilot/scenes.py +99 -0
  60. package/remote_script/LivePilot/server.py +293 -0
  61. package/remote_script/LivePilot/tracks.py +268 -0
  62. package/remote_script/LivePilot/transport.py +151 -0
  63. package/remote_script/LivePilot/utils.py +123 -0
  64. package/requirements.txt +2 -0
@@ -0,0 +1,210 @@
1
+ """TCP client for communicating with Ableton Live's Remote Script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import time
9
+ import uuid
10
+ from collections import deque
11
+ from typing import Optional
12
+
13
+ CONNECT_TIMEOUT = 5
14
+ RECV_TIMEOUT = 20
15
+
16
+
17
+ class AbletonConnectionError(Exception):
18
+ """Raised when communication with Ableton Live fails."""
19
+ pass
20
+
21
+
22
+ # Error messages with user-friendly context
23
+ _ERROR_HINTS = {
24
+ "INDEX_ERROR": "Check that the track, clip, device, or scene index exists. "
25
+ "Use get_session_info to see current indices.",
26
+ "NOT_FOUND": "The requested item could not be found in the Live session. "
27
+ "Verify names and indices with get_session_info or get_track_info.",
28
+ "INVALID_PARAM": "A parameter value was out of range or the wrong type. "
29
+ "Use get_device_parameters to check valid ranges.",
30
+ "STATE_ERROR": "The operation isn't valid in the current state. "
31
+ "For example, you can't add notes to a clip that doesn't exist yet.",
32
+ "TIMEOUT": "Ableton took too long to respond. This can happen with heavy sessions. "
33
+ "Try again, or check if Ableton is unresponsive.",
34
+ }
35
+
36
+
37
+ def _friendly_error(code: str, message: str, command_type: str) -> str:
38
+ """Format an error from the Remote Script into a user-friendly message."""
39
+ hint = _ERROR_HINTS.get(code, "")
40
+ parts = [f"[{code}] {message}"]
41
+ if hint:
42
+ parts.append(hint)
43
+ return " ".join(parts)
44
+
45
+
46
+ class AbletonConnection:
47
+ """TCP client that sends JSON commands to the LivePilot Remote Script."""
48
+
49
+ MAX_LOG_ENTRIES = 50
50
+
51
+ def __init__(self, host: Optional[str] = None, port: Optional[int] = None):
52
+ self.host = host or os.environ.get("LIVE_MCP_HOST", "127.0.0.1")
53
+ self.port = port or int(os.environ.get("LIVE_MCP_PORT", "9878"))
54
+ self._socket: Optional[socket.socket] = None
55
+ self._recv_buf: bytes = b""
56
+ self._command_log: deque[dict] = deque(maxlen=self.MAX_LOG_ENTRIES)
57
+
58
+ # ------------------------------------------------------------------
59
+ # Connection lifecycle
60
+ # ------------------------------------------------------------------
61
+
62
+ def connect(self) -> None:
63
+ """Open a TCP connection to the Remote Script."""
64
+ try:
65
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
66
+ sock.settimeout(CONNECT_TIMEOUT)
67
+ sock.connect((self.host, self.port))
68
+ sock.settimeout(RECV_TIMEOUT)
69
+ self._socket = sock
70
+ except ConnectionRefusedError:
71
+ self._socket = None
72
+ raise AbletonConnectionError(
73
+ f"Cannot reach Ableton Live on {self.host}:{self.port}. "
74
+ "Make sure Ableton Live is running and the LivePilot Remote Script "
75
+ "is enabled in Preferences > Link, Tempo & MIDI > Control Surface. "
76
+ "Run 'npx livepilot --doctor' for a full diagnostic."
77
+ )
78
+ except OSError as exc:
79
+ self._socket = None
80
+ raise AbletonConnectionError(
81
+ f"Could not connect to Ableton Live at {self.host}:{self.port} — {exc}"
82
+ ) from exc
83
+
84
+ def disconnect(self) -> None:
85
+ """Close the TCP connection."""
86
+ if self._socket is not None:
87
+ try:
88
+ self._socket.close()
89
+ except OSError:
90
+ pass
91
+ self._socket = None
92
+
93
+ def is_connected(self) -> bool:
94
+ """Return True if a socket is currently held."""
95
+ return self._socket is not None
96
+
97
+ # ------------------------------------------------------------------
98
+ # High-level API
99
+ # ------------------------------------------------------------------
100
+
101
+ def ping(self) -> bool:
102
+ """Send a ping and return True if a pong is received."""
103
+ try:
104
+ resp = self._send_raw({"type": "ping"})
105
+ return resp.get("result", {}).get("pong") is True
106
+ except Exception:
107
+ return False
108
+
109
+ def send_command(self, command_type: str, params: Optional[dict] = None) -> dict:
110
+ """Send a command to Ableton and return the result dict.
111
+
112
+ Retries once on socket errors with a fresh connection.
113
+ """
114
+ # Ensure we have a connection
115
+ if not self.is_connected():
116
+ self.connect()
117
+
118
+ command: dict = {"type": command_type}
119
+ if params:
120
+ command["params"] = params
121
+
122
+ try:
123
+ response = self._send_raw(command)
124
+ except (OSError, AbletonConnectionError):
125
+ # Retry once with a fresh connection
126
+ self.disconnect()
127
+ self.connect()
128
+ response = self._send_raw(command)
129
+
130
+ # Log the command and response
131
+ log_entry = {
132
+ "command": command_type,
133
+ "params": params,
134
+ "timestamp": time.time(),
135
+ "ok": response.get("ok", True),
136
+ }
137
+
138
+ # Handle error responses — Remote Script uses {"ok": false, "error": {"code": ..., "message": ...}}
139
+ if response.get("ok") is False:
140
+ err = response.get("error", {})
141
+ code = err.get("code", "INTERNAL") if isinstance(err, dict) else "INTERNAL"
142
+ message = err.get("message", str(err)) if isinstance(err, dict) else str(err)
143
+ log_entry["error"] = code
144
+ self._command_log.append(log_entry)
145
+ friendly = _friendly_error(code, message, command_type)
146
+ raise AbletonConnectionError(friendly)
147
+
148
+ self._command_log.append(log_entry)
149
+ return response.get("result", {})
150
+
151
+ # ------------------------------------------------------------------
152
+ # Command log
153
+ # ------------------------------------------------------------------
154
+
155
+ def get_recent_commands(self, limit: int = 20) -> list[dict]:
156
+ """Return the most recent commands sent to Ableton (newest first)."""
157
+ entries = list(self._command_log)
158
+ entries.reverse()
159
+ return entries[:limit]
160
+
161
+ # ------------------------------------------------------------------
162
+ # Low-level transport
163
+ # ------------------------------------------------------------------
164
+
165
+ def _send_raw(self, command: dict) -> dict:
166
+ """Send a JSON command (with request_id) and read the response."""
167
+ if self._socket is None:
168
+ raise AbletonConnectionError("Not connected to Ableton Live")
169
+
170
+ # Don't mutate the caller's dict
171
+ envelope = {**command, "id": str(uuid.uuid4())[:8]}
172
+ payload = json.dumps(envelope) + "\n"
173
+
174
+ try:
175
+ self._socket.sendall(payload.encode("utf-8"))
176
+ except OSError as exc:
177
+ self.disconnect()
178
+ raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
179
+
180
+ # Read until newline, preserving any trailing bytes in _recv_buf
181
+ buf = getattr(self, "_recv_buf", b"")
182
+ try:
183
+ while b"\n" not in buf:
184
+ chunk = self._socket.recv(4096)
185
+ if not chunk:
186
+ self._recv_buf = b""
187
+ self.disconnect()
188
+ raise AbletonConnectionError("Connection closed by Ableton")
189
+ buf += chunk
190
+ except socket.timeout as exc:
191
+ self._recv_buf = buf
192
+ self.disconnect()
193
+ raise AbletonConnectionError(
194
+ f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
195
+ ) from exc
196
+ except OSError as exc:
197
+ self._recv_buf = b""
198
+ self.disconnect()
199
+ raise AbletonConnectionError(
200
+ f"Socket error reading response: {exc}"
201
+ ) from exc
202
+
203
+ line, remainder = buf.split(b"\n", 1)
204
+ self._recv_buf = remainder
205
+ try:
206
+ return json.loads(line)
207
+ except json.JSONDecodeError as exc:
208
+ raise AbletonConnectionError(
209
+ f"Invalid JSON from Ableton: {line[:200]}"
210
+ ) from exc
@@ -0,0 +1,5 @@
1
+ """LivePilot technique memory — persistent storage for learned patterns."""
2
+
3
+ from .technique_store import TechniqueStore
4
+
5
+ __all__ = ["TechniqueStore"]
@@ -0,0 +1,296 @@
1
+ """Persistent JSON store for LivePilot techniques (beat patterns, device chains, etc.)."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+
12
+ VALID_TYPES = frozenset(
13
+ ["beat_pattern", "device_chain", "mix_template", "browser_pin", "preference"]
14
+ )
15
+
16
+ VALID_SORT_FIELDS = frozenset(
17
+ ["updated_at", "created_at", "rating", "replay_count", "name"]
18
+ )
19
+
20
+
21
+ class TechniqueStore:
22
+ """Thread-safe JSON-backed store for techniques."""
23
+
24
+ def __init__(self, base_dir: Optional[str] = None):
25
+ if base_dir is None:
26
+ base_dir = os.path.join(os.path.expanduser("~"), ".livepilot", "memory")
27
+ self._base_dir = Path(base_dir)
28
+ self._base_dir.mkdir(parents=True, exist_ok=True)
29
+ self._file = self._base_dir / "techniques.json"
30
+ self._lock = threading.Lock()
31
+
32
+ if self._file.exists():
33
+ try:
34
+ with open(self._file, "r") as f:
35
+ self._data = json.load(f)
36
+ except (json.JSONDecodeError, ValueError):
37
+ corrupt = self._file.with_suffix(".json.corrupt")
38
+ self._file.rename(corrupt)
39
+ self._data = {"version": 1, "techniques": []}
40
+ else:
41
+ self._data = {"version": 1, "techniques": []}
42
+ self._flush()
43
+
44
+ # ── persistence ──────────────────────────────────────────────
45
+
46
+ def _flush(self) -> None:
47
+ """Atomic write: tmp file then rename."""
48
+ tmp = self._file.with_suffix(".tmp")
49
+ with open(tmp, "w") as f:
50
+ json.dump(self._data, f, indent=2)
51
+ f.flush()
52
+ os.fsync(f.fileno())
53
+ os.replace(str(tmp), str(self._file))
54
+
55
+ # ── public API ───────────────────────────────────────────────
56
+
57
+ def save(
58
+ self,
59
+ name: str,
60
+ type: str,
61
+ qualities: dict,
62
+ payload: dict,
63
+ tags: Optional[list[str]] = None,
64
+ ) -> dict:
65
+ """Create a new technique. Returns {id, name, type, summary}."""
66
+ if type not in VALID_TYPES:
67
+ raise ValueError(
68
+ f"INVALID_PARAM: type must be one of {sorted(VALID_TYPES)}, got '{type}'"
69
+ )
70
+ if not qualities.get("summary"):
71
+ raise ValueError("INVALID_PARAM: qualities must contain non-empty 'summary'")
72
+
73
+ now = datetime.now(timezone.utc).isoformat()
74
+ technique = {
75
+ "id": str(uuid.uuid4()),
76
+ "name": name,
77
+ "type": type,
78
+ "qualities": qualities,
79
+ "payload": payload,
80
+ "tags": tags or [],
81
+ "created_at": now,
82
+ "updated_at": now,
83
+ "favorite": False,
84
+ "rating": 0,
85
+ "replay_count": 0,
86
+ }
87
+
88
+ with self._lock:
89
+ self._data["techniques"].append(technique)
90
+ self._flush()
91
+
92
+ return {
93
+ "id": technique["id"],
94
+ "name": technique["name"],
95
+ "type": technique["type"],
96
+ "summary": qualities["summary"],
97
+ }
98
+
99
+ def get(self, technique_id: str) -> dict:
100
+ """Return full technique by id."""
101
+ with self._lock:
102
+ for t in self._data["techniques"]:
103
+ if t["id"] == technique_id:
104
+ return dict(t)
105
+ raise ValueError(f"NOT_FOUND: technique '{technique_id}' does not exist")
106
+
107
+ def search(
108
+ self,
109
+ query: Optional[str] = None,
110
+ type_filter: Optional[str] = None,
111
+ tags: Optional[list[str]] = None,
112
+ limit: int = 10,
113
+ ) -> list[dict]:
114
+ """Search techniques. Returns summaries (no payload)."""
115
+ with self._lock:
116
+ results = list(self._data["techniques"])
117
+
118
+ # filter by type
119
+ if type_filter:
120
+ results = [t for t in results if t["type"] == type_filter]
121
+
122
+ # filter by tags (match any)
123
+ if tags:
124
+ tag_set = set(tags)
125
+ results = [t for t in results if tag_set & set(t.get("tags", []))]
126
+
127
+ # text search — all query words must appear somewhere in the technique
128
+ if query:
129
+ words = query.lower().split()
130
+ filtered = []
131
+ for t in results:
132
+ searchable = self._searchable_text(t)
133
+ if all(w in searchable for w in words):
134
+ filtered.append(t)
135
+ results = filtered
136
+
137
+ results = self._multi_sort(results)
138
+
139
+ results = results[:limit]
140
+
141
+ # strip payload
142
+ return [self._summary(t) for t in results]
143
+
144
+ def list_techniques(
145
+ self,
146
+ type_filter: Optional[str] = None,
147
+ tags: Optional[list[str]] = None,
148
+ sort_by: str = "updated_at",
149
+ limit: int = 20,
150
+ ) -> list[dict]:
151
+ """List techniques as compact summaries."""
152
+ if sort_by not in VALID_SORT_FIELDS:
153
+ raise ValueError(
154
+ f"INVALID_PARAM: sort_by must be one of {sorted(VALID_SORT_FIELDS)}, got '{sort_by}'"
155
+ )
156
+
157
+ with self._lock:
158
+ results = list(self._data["techniques"])
159
+
160
+ if type_filter:
161
+ results = [t for t in results if t["type"] == type_filter]
162
+
163
+ if tags:
164
+ tag_set = set(tags)
165
+ results = [t for t in results if tag_set & set(t.get("tags", []))]
166
+
167
+ reverse = sort_by != "name"
168
+ results.sort(key=lambda t: t.get(sort_by, ""), reverse=reverse)
169
+
170
+ results = results[:limit]
171
+
172
+ return [self._compact_summary(t) for t in results]
173
+
174
+ def favorite(
175
+ self,
176
+ technique_id: str,
177
+ favorite: Optional[bool] = None,
178
+ rating: Optional[int] = None,
179
+ ) -> dict:
180
+ """Set favorite flag and/or rating."""
181
+ if rating is not None and (rating < 0 or rating > 5):
182
+ raise ValueError("INVALID_PARAM: rating must be between 0 and 5")
183
+
184
+ with self._lock:
185
+ t = self._find(technique_id)
186
+ if favorite is not None:
187
+ t["favorite"] = favorite
188
+ if rating is not None:
189
+ t["rating"] = rating
190
+ t["updated_at"] = datetime.now(timezone.utc).isoformat()
191
+ self._flush()
192
+ return self._compact_summary(t)
193
+
194
+ def update(
195
+ self,
196
+ technique_id: str,
197
+ name: Optional[str] = None,
198
+ tags: Optional[list[str]] = None,
199
+ qualities: Optional[dict] = None,
200
+ ) -> dict:
201
+ """Update technique fields. Qualities are merged (lists replaced)."""
202
+ with self._lock:
203
+ t = self._find(technique_id)
204
+ if name is not None:
205
+ t["name"] = name
206
+ if tags is not None:
207
+ t["tags"] = tags
208
+ if qualities is not None:
209
+ existing = t.get("qualities", {})
210
+ existing.update(qualities)
211
+ t["qualities"] = existing
212
+ t["updated_at"] = datetime.now(timezone.utc).isoformat()
213
+ self._flush()
214
+ return self._compact_summary(t)
215
+
216
+ def delete(self, technique_id: str) -> dict:
217
+ """Delete technique after creating a timestamped backup."""
218
+ with self._lock:
219
+ t = self._find(technique_id)
220
+ # backup
221
+ backup_dir = self._base_dir / "backups"
222
+ backup_dir.mkdir(exist_ok=True)
223
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
224
+ backup_file = backup_dir / f"{technique_id}_{ts}.json"
225
+ with open(backup_file, "w") as f:
226
+ json.dump(t, f, indent=2)
227
+ # remove
228
+ self._data["techniques"] = [
229
+ x for x in self._data["techniques"] if x["id"] != technique_id
230
+ ]
231
+ self._flush()
232
+ return {"id": technique_id, "deleted": True}
233
+
234
+ def increment_replay(self, technique_id: str) -> None:
235
+ """Increment replay_count and set last_replayed_at."""
236
+ with self._lock:
237
+ t = self._find(technique_id)
238
+ t["replay_count"] = t.get("replay_count", 0) + 1
239
+ t["last_replayed_at"] = datetime.now(timezone.utc).isoformat()
240
+ self._flush()
241
+
242
+ # ── private helpers ──────────────────────────────────────────
243
+
244
+ def _find(self, technique_id: str) -> dict:
245
+ """Find technique by id (must hold lock). Returns mutable ref."""
246
+ for t in self._data["techniques"]:
247
+ if t["id"] == technique_id:
248
+ return t
249
+ raise ValueError(f"NOT_FOUND: technique '{technique_id}' does not exist")
250
+
251
+ @staticmethod
252
+ def _searchable_text(t: dict) -> str:
253
+ """Build a single lowercase string from all searchable fields."""
254
+ parts = [t.get("name", "")]
255
+ parts.extend(t.get("tags", []))
256
+ for v in t.get("qualities", {}).values():
257
+ if isinstance(v, str):
258
+ parts.append(v)
259
+ elif isinstance(v, list):
260
+ parts.extend(str(item) for item in v)
261
+ return " ".join(parts).lower()
262
+
263
+ @staticmethod
264
+ def _multi_sort(results: list[dict]) -> list[dict]:
265
+ """Sort: favorites first, rating desc, replay_count desc, updated_at desc."""
266
+ return sorted(
267
+ results,
268
+ key=lambda t: (
269
+ t.get("favorite", False),
270
+ t.get("rating", 0),
271
+ t.get("replay_count", 0),
272
+ t.get("updated_at", ""),
273
+ ),
274
+ reverse=True,
275
+ )
276
+
277
+ @staticmethod
278
+ def _summary(t: dict) -> dict:
279
+ """Everything except payload."""
280
+ return {k: v for k, v in t.items() if k != "payload"}
281
+
282
+ @staticmethod
283
+ def _compact_summary(t: dict) -> dict:
284
+ """Compact: id, name, type, tags, summary, favorite, rating, replay_count, timestamps."""
285
+ return {
286
+ "id": t["id"],
287
+ "name": t["name"],
288
+ "type": t["type"],
289
+ "tags": t.get("tags", []),
290
+ "summary": t.get("qualities", {}).get("summary", ""),
291
+ "favorite": t.get("favorite", False),
292
+ "rating": t.get("rating", 0),
293
+ "replay_count": t.get("replay_count", 0),
294
+ "created_at": t.get("created_at", ""),
295
+ "updated_at": t.get("updated_at", ""),
296
+ }
@@ -0,0 +1,87 @@
1
+ """FastMCP entry point for LivePilot."""
2
+
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastmcp import FastMCP, Context # noqa: F401
6
+
7
+ from .connection import AbletonConnection
8
+
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(server):
12
+ """Create and yield the shared AbletonConnection for all tools."""
13
+ ableton = AbletonConnection()
14
+ try:
15
+ yield {"ableton": ableton}
16
+ finally:
17
+ ableton.disconnect()
18
+
19
+
20
+ mcp = FastMCP("LivePilot", lifespan=lifespan)
21
+
22
+ # Import tool modules so they register with `mcp`
23
+ from .tools import transport # noqa: F401, E402
24
+ from .tools import tracks # noqa: F401, E402
25
+ from .tools import clips # noqa: F401, E402
26
+ from .tools import notes # noqa: F401, E402
27
+ from .tools import devices # noqa: F401, E402
28
+ from .tools import scenes # noqa: F401, E402
29
+ from .tools import mixing # noqa: F401, E402
30
+ from .tools import browser # noqa: F401, E402
31
+ from .tools import arrangement # noqa: F401, E402
32
+ from .tools import memory # noqa: F401, E402
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Schema coercion patch — accept strings for numeric parameters
37
+ # ---------------------------------------------------------------------------
38
+ # Some MCP clients (with deferred tools) send all parameter
39
+ # values as strings. Their client-side Zod validators reject "0" against
40
+ # {"type": "integer"} before the request even reaches our server.
41
+ #
42
+ # Fix: widen every integer/number property in the advertised JSON Schema to
43
+ # also accept strings. Server-side Pydantic validation (lax mode) coerces
44
+ # "5" → 5 and "0.75" → 0.75 automatically, so no tool code changes needed.
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _coerce_schema_property(prop: dict) -> None:
48
+ """Widen a single JSON Schema property to also accept strings."""
49
+ if prop.get("type") in ("integer", "number"):
50
+ original_type = prop.pop("type")
51
+ prop["anyOf"] = [{"type": original_type}, {"type": "string"}]
52
+ elif "anyOf" in prop:
53
+ for variant in prop["anyOf"]:
54
+ _coerce_schema_property(variant)
55
+
56
+
57
+ def _get_all_tools():
58
+ """Get all registered tools, compatible with FastMCP 0.x and 3.x."""
59
+ # FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
60
+ if hasattr(mcp, "_tool_manager"):
61
+ return list(mcp._tool_manager._tools.values())
62
+ # FastMCP 3.x: mcp._local_provider._components (dict of key -> Tool)
63
+ if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
64
+ return list(mcp._local_provider._components.values())
65
+ return []
66
+
67
+
68
+ def _patch_tool_schemas() -> None:
69
+ """Post-process all registered tool schemas for string coercion."""
70
+ for tool in _get_all_tools():
71
+ props = tool.parameters.get("properties", {})
72
+ for name, prop in props.items():
73
+ if name == "ctx":
74
+ continue # skip the Context parameter
75
+ _coerce_schema_property(prop)
76
+
77
+
78
+ _patch_tool_schemas()
79
+
80
+
81
+ def main():
82
+ """Run the MCP server over stdio."""
83
+ mcp.run(transport="stdio")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
@@ -0,0 +1 @@
1
+ """MCP tool modules for LivePilot."""