laive-mcp 0.1.3 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.0 - 2026-03-22
6
+
7
+ - Updated the README to advertise the currently proven live MCP capabilities separately from lower-level bridge capabilities that are not yet surfaced as first-class MCP tools.
8
+ - Expanded the MCP server to expose the remaining control-surface bridge tools for transport control, scene creation, and MIDI note insertion.
9
+ - Added optional sidecar and UI-helper MCP workflow tools plus `get_component_status`, with structured setup instructions when those optional components are unavailable.
10
+ - Fixed the Remote Script packaging helper to retry staged-tree cleanup so `laive-mcp package` no longer fails intermittently on existing `__pycache__` directories.
11
+
12
+ ## v0.1.4 - 2026-03-22
13
+
14
+ - Fixed MCP tool schema advertising so argument-bearing tools like `set_tempo`, `get_track_details`, `get_device_tree`, `create_clip`, and `set_parameter` now publish explicit JSON Schemas through `tools/list` instead of empty input objects, allowing Codex clients to send required parameters.
15
+
5
16
  ## v0.1.3 - 2026-03-22
6
17
 
7
18
  - Fixed MCP `tools/call` responses to return proper `CallToolResult` envelopes with `content`, `structuredContent`, and `isError`, so Codex clients accept the responses instead of rejecting them as an unexpected type.
package/README.md CHANGED
@@ -11,6 +11,36 @@ Today, the repo ships:
11
11
  - a staged `laive-ui-helper.app` bundle for UI fallback permissions
12
12
  - `.als` parsing scaffolds
13
13
 
14
+ ## Proven MCP Capabilities
15
+
16
+ With Ableton Live running and the `laive` Control Surface enabled, the published MCP server can currently drive these workflows end-to-end:
17
+
18
+ - read the current project summary
19
+ - read the selected track, scene, clip, and device context
20
+ - list tracks
21
+ - read detailed track state, including session clips
22
+ - read a track's device tree and parameter state
23
+ - refresh the mirrored project state
24
+ - set song tempo
25
+ - start and stop transport
26
+ - create MIDI or audio tracks
27
+ - create scenes
28
+ - create MIDI clips in session slots
29
+ - insert MIDI notes into clips
30
+ - set device parameter values
31
+ - report optional sidecar and UI-helper availability with setup guidance
32
+ - list and invoke optional sidecar workflows
33
+ - list and invoke optional UI-helper workflows
34
+
35
+ These capabilities have been validated against a live Ableton session through the published `laive-mcp` package, not just fixture mode.
36
+
37
+ The optional components are intentionally soft-failable:
38
+
39
+ - if the Max for Live sidecar is not installed or not connected, the MCP tools return structured setup instructions for the agent to relay
40
+ - if the macOS UI helper is not installed or Accessibility is not granted, the MCP tools return setup instructions instead of silently failing
41
+
42
+ The bridge also reports lower-level support for subscriptions / event streaming, but that is not yet surfaced as a first-class MCP notification channel in the current release.
43
+
14
44
  If you are using this as an end user, the published npm entrypoint is `laive-mcp`. The Ableton-side control surface name remains `laive`.
15
45
 
16
46
  ## Published Package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laive-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Local MCP, install tooling, and helper assets for controlling Ableton Live.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -144,13 +144,14 @@ class LiveSetAdapter(object):
144
144
 
145
145
  def insert_notes(self, clip_id, notes, dry_run=False):
146
146
  clip, track_id, slot_index = self._find_clip(clip_id)
147
+ normalized_notes = [self._tuple_note(note) for note in notes]
147
148
  if not dry_run:
148
149
  if hasattr(clip, "add_new_notes"):
149
- clip.add_new_notes(notes)
150
+ clip.add_new_notes(tuple(normalized_notes))
150
151
  elif hasattr(clip, "set_notes"):
151
- clip.set_notes(tuple(self._tuple_note(note) for note in notes))
152
+ clip.set_notes(tuple(normalized_notes))
152
153
  else:
153
- clip.notes.extend(notes)
154
+ clip.notes.extend(normalized_notes)
154
155
  note_count = len(notes)
155
156
  clip_state = self._serialize_clip(clip, track_id, slot_index)
156
157
  clip_state["note_count"] = len(getattr(clip, "notes", [])) if not dry_run else note_count
@@ -265,8 +266,8 @@ class LiveSetAdapter(object):
265
266
  def _tuple_note(self, note):
266
267
  return (
267
268
  note.get("pitch", 60),
268
- note.get("start_beats", 0.0),
269
- note.get("duration_beats", 0.25),
269
+ note.get("start_beats", note.get("startBeats", 0.0)),
270
+ note.get("duration_beats", note.get("durationBeats", 0.25)),
270
271
  note.get("velocity", 100),
271
272
  bool(note.get("mute", False)),
272
273
  )
@@ -3,6 +3,11 @@ import readline from "node:readline";
3
3
  import process from "node:process";
4
4
 
5
5
  import { LaiveMcpServer } from "./server.js";
6
+ import {
7
+ createIntegrationStatusAdapter,
8
+ createSidecarAdapter,
9
+ createUiAutomationAdapter
10
+ } from "./optional-adapters.js";
6
11
  import {
7
12
  LaiveBridgeSession,
8
13
  LaiveFixtureSession,
@@ -53,10 +58,23 @@ async function createSession(options) {
53
58
  async function main() {
54
59
  const options = parseArgs();
55
60
  const session = await createSession(options);
61
+ const stateAdapter = createStateAdapter(session);
62
+ const bridgeAdapter = createBridgeAdapter(session);
63
+ const sidecarAdapter = createSidecarAdapter({
64
+ stateAdapter,
65
+ bridgeAdapter
66
+ });
67
+ const uiAutomationAdapter = createUiAutomationAdapter();
56
68
  const server = new LaiveMcpServer({
57
- stateAdapter: createStateAdapter(session),
58
- bridgeAdapter: createBridgeAdapter(session),
59
- policyAdapter: createAllowAllPolicyAdapter()
69
+ stateAdapter,
70
+ bridgeAdapter,
71
+ sidecarAdapter,
72
+ uiAutomationAdapter,
73
+ integrationStatusAdapter: createIntegrationStatusAdapter({
74
+ sidecarAdapter,
75
+ uiAutomationAdapter
76
+ }),
77
+ policyAdapter: createAllowAllPolicyAdapter()
60
78
  });
61
79
  let lineReader = null;
62
80
 
@@ -1,5 +1,20 @@
1
1
  import { McpServerError } from "./errors.js";
2
2
 
3
+ const EMPTY_OBJECT_SCHEMA = {
4
+ type: "object",
5
+ properties: {},
6
+ additionalProperties: false
7
+ };
8
+
9
+ function createObjectSchema({ properties = {}, required = [] } = {}) {
10
+ return {
11
+ type: "object",
12
+ properties,
13
+ required,
14
+ additionalProperties: false
15
+ };
16
+ }
17
+
3
18
  function requireString(value, fieldName) {
4
19
  if (typeof value !== "string" || value.length === 0) {
5
20
  throw new McpServerError(
@@ -9,6 +24,15 @@ function requireString(value, fieldName) {
9
24
  }
10
25
  }
11
26
 
27
+ function requireNotes(notes) {
28
+ if (!Array.isArray(notes) || notes.length === 0) {
29
+ throw new McpServerError(
30
+ "invalid_request",
31
+ "notes must be a non-empty array of MIDI note objects"
32
+ );
33
+ }
34
+ }
35
+
12
36
  function buildMutationResult(summary, affectedObjects, beforeVersion, afterVersion, warnings = []) {
13
37
  return {
14
38
  summary,
@@ -20,11 +44,83 @@ function buildMutationResult(summary, affectedObjects, beforeVersion, afterVersi
20
44
  };
21
45
  }
22
46
 
23
- export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }) {
47
+ function buildInformationalResult(summary, payload = {}, nextActions = []) {
48
+ return {
49
+ summary,
50
+ affected_objects: payload.affected_objects ?? [],
51
+ state_version_before: payload.state_version_before ?? null,
52
+ state_version_after: payload.state_version_after ?? null,
53
+ warnings: payload.warnings ?? [],
54
+ next_suggested_actions: nextActions,
55
+ ...payload
56
+ };
57
+ }
58
+
59
+ function buildWorkflowSchema(description) {
60
+ return {
61
+ type: "object",
62
+ properties: {
63
+ name: {
64
+ type: "string",
65
+ description
66
+ },
67
+ parameters: {
68
+ type: "object",
69
+ description: "Workflow-specific parameters.",
70
+ additionalProperties: true
71
+ }
72
+ },
73
+ required: ["name"],
74
+ additionalProperties: false
75
+ };
76
+ }
77
+
78
+ const noteItemSchema = {
79
+ type: "object",
80
+ properties: {
81
+ pitch: {
82
+ type: "integer",
83
+ minimum: 0,
84
+ maximum: 127
85
+ },
86
+ startBeats: {
87
+ type: "number",
88
+ minimum: 0
89
+ },
90
+ durationBeats: {
91
+ type: "number",
92
+ exclusiveMinimum: 0
93
+ },
94
+ velocity: {
95
+ type: "integer",
96
+ minimum: 1,
97
+ maximum: 127
98
+ },
99
+ mute: {
100
+ type: "boolean"
101
+ }
102
+ },
103
+ required: ["pitch", "startBeats", "durationBeats", "velocity"],
104
+ additionalProperties: false
105
+ };
106
+
107
+ const dryRunProperty = {
108
+ type: "boolean",
109
+ description: "If true, preview the action without mutating Live."
110
+ };
111
+
112
+ export function buildDefaultTools({
113
+ stateAdapter,
114
+ bridgeAdapter,
115
+ policyAdapter,
116
+ sidecarAdapter,
117
+ uiAutomationAdapter
118
+ }) {
24
119
  return [
25
120
  {
26
121
  name: "get_project_summary",
27
122
  description: "Return a compact summary of the current Live set state.",
123
+ inputSchema: EMPTY_OBJECT_SCHEMA,
28
124
  async execute() {
29
125
  const summary = await stateAdapter.getProjectSummary();
30
126
  return {
@@ -41,6 +137,7 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
41
137
  {
42
138
  name: "get_selected_context",
43
139
  description: "Return the selected track, scene, clip, and device context.",
140
+ inputSchema: EMPTY_OBJECT_SCHEMA,
44
141
  async execute() {
45
142
  const context = await stateAdapter.getSelectedContext();
46
143
  return {
@@ -59,6 +156,7 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
59
156
  {
60
157
  name: "list_tracks",
61
158
  description: "List tracks in compact form.",
159
+ inputSchema: EMPTY_OBJECT_SCHEMA,
62
160
  async execute() {
63
161
  const tracks = await stateAdapter.listTracks();
64
162
  return {
@@ -75,6 +173,23 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
75
173
  {
76
174
  name: "get_track_details",
77
175
  description: "Return detailed state for a track identified by ID, name, or index.",
176
+ inputSchema: createObjectSchema({
177
+ properties: {
178
+ id: {
179
+ type: "string",
180
+ description: "Track identifier, for example `track:7`."
181
+ },
182
+ name: {
183
+ type: "string",
184
+ description: "Exact track name."
185
+ },
186
+ index: {
187
+ type: "integer",
188
+ minimum: 0,
189
+ description: "Zero-based visible-track index."
190
+ }
191
+ }
192
+ }),
78
193
  async execute(args) {
79
194
  const target = args.id ?? args.name ?? args.index;
80
195
  if (target === undefined) {
@@ -99,6 +214,15 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
99
214
  {
100
215
  name: "get_device_tree",
101
216
  description: "Return device state for a track.",
217
+ inputSchema: createObjectSchema({
218
+ properties: {
219
+ trackId: {
220
+ type: "string",
221
+ description: "Track identifier, for example `track:7`."
222
+ }
223
+ },
224
+ required: ["trackId"]
225
+ }),
102
226
  async execute(args) {
103
227
  const trackId = args.trackId ?? args.track ?? args.id;
104
228
  requireString(trackId, "trackId");
@@ -114,9 +238,52 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
114
238
  };
115
239
  }
116
240
  },
241
+ {
242
+ name: "get_component_status",
243
+ description:
244
+ "Report control-surface, Max sidecar, and UI-helper availability, including setup guidance for optional components.",
245
+ inputSchema: EMPTY_OBJECT_SCHEMA,
246
+ async execute() {
247
+ const [bridgeCapabilities, sidecarStatus, uiHelperStatus] = await Promise.all([
248
+ bridgeAdapter.getCapabilities(),
249
+ sidecarAdapter.getStatus(),
250
+ uiAutomationAdapter.getStatus()
251
+ ]);
252
+
253
+ return buildInformationalResult(
254
+ "Component status loaded.",
255
+ {
256
+ affected_objects: ["bridge", "sidecar", "ui_helper"],
257
+ components: {
258
+ bridge: {
259
+ available: true,
260
+ capabilities: bridgeCapabilities
261
+ },
262
+ sidecar: sidecarStatus,
263
+ ui_helper: uiHelperStatus
264
+ }
265
+ },
266
+ ["get_capabilities", "list_sidecar_workflows", "list_ui_workflows"]
267
+ );
268
+ }
269
+ },
117
270
  {
118
271
  name: "set_tempo",
119
272
  description: "Update the current song tempo.",
273
+ inputSchema: createObjectSchema({
274
+ properties: {
275
+ tempo: {
276
+ type: "number",
277
+ exclusiveMinimum: 0,
278
+ description: "Target song tempo in BPM."
279
+ },
280
+ dryRun: {
281
+ type: "boolean",
282
+ description: "If true, preview the action without mutating Live."
283
+ }
284
+ },
285
+ required: ["tempo"]
286
+ }),
120
287
  async execute(args) {
121
288
  const nextTempo = Number(args.tempo);
122
289
  if (!Number.isFinite(nextTempo) || nextTempo <= 0) {
@@ -136,9 +303,72 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
136
303
  );
137
304
  }
138
305
  },
306
+ {
307
+ name: "play_transport",
308
+ description: "Start Ableton Live transport playback.",
309
+ inputSchema: createObjectSchema({
310
+ properties: {
311
+ dryRun: {
312
+ type: "boolean",
313
+ description: "If true, preview the action without mutating Live."
314
+ }
315
+ }
316
+ }),
317
+ async execute(args) {
318
+ await policyAdapter.assertAllowed("play_transport", args);
319
+ const before = await stateAdapter.getProjectSummary();
320
+ await bridgeAdapter.playTransport({ dryRun: Boolean(args.dryRun) });
321
+ const after = await stateAdapter.refreshState("song");
322
+ return buildMutationResult(
323
+ `Transport ${args.dryRun ? "play previewed" : "started"}.`,
324
+ ["song"],
325
+ before.stateVersion,
326
+ after.stateVersion,
327
+ after.warnings ?? []
328
+ );
329
+ }
330
+ },
331
+ {
332
+ name: "stop_transport",
333
+ description: "Stop Ableton Live transport playback.",
334
+ inputSchema: createObjectSchema({
335
+ properties: {
336
+ dryRun: {
337
+ type: "boolean",
338
+ description: "If true, preview the action without mutating Live."
339
+ }
340
+ }
341
+ }),
342
+ async execute(args) {
343
+ await policyAdapter.assertAllowed("stop_transport", args);
344
+ const before = await stateAdapter.getProjectSummary();
345
+ await bridgeAdapter.stopTransport({ dryRun: Boolean(args.dryRun) });
346
+ const after = await stateAdapter.refreshState("song");
347
+ return buildMutationResult(
348
+ `Transport ${args.dryRun ? "stop previewed" : "stopped"}.`,
349
+ ["song"],
350
+ before.stateVersion,
351
+ after.stateVersion,
352
+ after.warnings ?? []
353
+ );
354
+ }
355
+ },
139
356
  {
140
357
  name: "create_track",
141
358
  description: "Create a new track.",
359
+ inputSchema: createObjectSchema({
360
+ properties: {
361
+ kind: {
362
+ type: "string",
363
+ enum: ["midi", "audio"],
364
+ description: "Track type to create."
365
+ },
366
+ dryRun: {
367
+ type: "boolean",
368
+ description: "If true, preview the action without mutating Live."
369
+ }
370
+ }
371
+ }),
142
372
  async execute(args) {
143
373
  const kind = args.kind ?? "midi";
144
374
  await policyAdapter.assertAllowed("create_track", args);
@@ -154,9 +384,67 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
154
384
  );
155
385
  }
156
386
  },
387
+ {
388
+ name: "create_scene",
389
+ description: "Create a new scene.",
390
+ inputSchema: createObjectSchema({
391
+ properties: {
392
+ name: {
393
+ type: "string",
394
+ description: "Optional scene name."
395
+ },
396
+ dryRun: {
397
+ type: "boolean",
398
+ description: "If true, preview the action without mutating Live."
399
+ }
400
+ }
401
+ }),
402
+ async execute(args) {
403
+ await policyAdapter.assertAllowed("create_scene", args);
404
+ const before = await stateAdapter.getProjectSummary();
405
+ const created = await bridgeAdapter.createScene(args.name ?? null, {
406
+ dryRun: Boolean(args.dryRun)
407
+ });
408
+ const after = await stateAdapter.refreshState("scenes");
409
+ return buildMutationResult(
410
+ `Scene ${args.dryRun ? "previewed" : "created"}.`,
411
+ created.affectedObjects ?? ["scenes"],
412
+ before.stateVersion,
413
+ after.stateVersion,
414
+ after.warnings ?? []
415
+ );
416
+ }
417
+ },
157
418
  {
158
419
  name: "create_clip",
159
420
  description: "Create a MIDI clip on a target track and slot.",
421
+ inputSchema: createObjectSchema({
422
+ properties: {
423
+ trackId: {
424
+ type: "string",
425
+ description: "Track identifier, for example `track:7`."
426
+ },
427
+ slotIndex: {
428
+ type: "integer",
429
+ minimum: 0,
430
+ description: "Zero-based session slot index on the target track."
431
+ },
432
+ lengthBeats: {
433
+ type: "number",
434
+ exclusiveMinimum: 0,
435
+ description: "Clip length in beats. Defaults to 4."
436
+ },
437
+ name: {
438
+ type: "string",
439
+ description: "Optional clip name."
440
+ },
441
+ dryRun: {
442
+ type: "boolean",
443
+ description: "If true, preview the action without mutating Live."
444
+ }
445
+ },
446
+ required: ["trackId", "slotIndex"]
447
+ }),
160
448
  async execute(args) {
161
449
  requireString(args.trackId, "trackId");
162
450
  if (!Number.isInteger(args.slotIndex) || args.slotIndex < 0) {
@@ -182,9 +470,78 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
182
470
  );
183
471
  }
184
472
  },
473
+ {
474
+ name: "insert_notes",
475
+ description: "Insert or replace notes in a target MIDI clip.",
476
+ inputSchema: createObjectSchema({
477
+ properties: {
478
+ clipId: {
479
+ type: "string",
480
+ description: "Canonical clip id such as clip:session:track:8:slot:1."
481
+ },
482
+ notes: {
483
+ type: "array",
484
+ items: noteItemSchema,
485
+ description: "Note payload to apply to the clip."
486
+ },
487
+ dryRun: {
488
+ type: "boolean",
489
+ description: "If true, preview the action without mutating Live."
490
+ }
491
+ },
492
+ required: ["clipId", "notes"]
493
+ }),
494
+ async execute(args) {
495
+ requireString(args.clipId, "clipId");
496
+ requireNotes(args.notes);
497
+
498
+ await policyAdapter.assertAllowed("insert_notes", args);
499
+ const before = await stateAdapter.getProjectSummary();
500
+ const inserted = await bridgeAdapter.insertNotes(
501
+ {
502
+ clipId: args.clipId,
503
+ notes: args.notes
504
+ },
505
+ { dryRun: Boolean(args.dryRun) }
506
+ );
507
+ const after = await stateAdapter.refreshState("project");
508
+ return buildMutationResult(
509
+ `Notes ${args.dryRun ? "previewed" : "inserted"} for ${args.clipId}.`,
510
+ inserted.affectedObjects ?? [args.clipId],
511
+ before.stateVersion,
512
+ after.stateVersion,
513
+ after.warnings ?? []
514
+ );
515
+ }
516
+ },
185
517
  {
186
518
  name: "set_parameter",
187
519
  description: "Set a device parameter by track/device/parameter identifiers.",
520
+ inputSchema: createObjectSchema({
521
+ properties: {
522
+ trackId: {
523
+ type: "string",
524
+ description: "Track identifier containing the target device."
525
+ },
526
+ deviceId: {
527
+ type: "string",
528
+ description: "Device identifier containing the target parameter."
529
+ },
530
+ parameterId: {
531
+ type: "string",
532
+ description: "Parameter identifier to update."
533
+ },
534
+ value: {
535
+ type: "number",
536
+ description: "Target numeric parameter value."
537
+ },
538
+ dryRun: {
539
+ type: "boolean",
540
+ description: "If true, preview the action without mutating Live."
541
+ }
542
+ },
543
+ required: ["trackId", "deviceId", "parameterId", "value"]
544
+ }),
188
545
  async execute(args) {
189
546
  requireString(args.trackId, "trackId");
190
547
  requireString(args.deviceId, "deviceId");
@@ -216,9 +573,299 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
216
573
  );
217
574
  }
218
575
  },
576
+ {
577
+ name: "list_sidecar_workflows",
578
+ description: "List optional Max for Live sidecar workflows and current availability.",
579
+ inputSchema: EMPTY_OBJECT_SCHEMA,
580
+ async execute() {
581
+ const result = await sidecarAdapter.listWorkflows();
582
+ return buildInformationalResult(
583
+ "Sidecar workflow status loaded.",
584
+ {
585
+ affected_objects: ["sidecar"],
586
+ sidecar: result
587
+ },
588
+ ["run_sidecar_workflow", "get_component_status"]
589
+ );
590
+ }
591
+ },
592
+ {
593
+ name: "sidecar_snapshot_selection_context",
594
+ description:
595
+ "Read selected track, clip, and device context through the optional Max for Live sidecar, or return setup instructions if it is unavailable.",
596
+ inputSchema: EMPTY_OBJECT_SCHEMA,
597
+ async execute() {
598
+ const result = await sidecarAdapter.snapshotSelectionContext();
599
+ return buildInformationalResult(
600
+ "Sidecar selection context loaded.",
601
+ {
602
+ affected_objects: Object.values(result.context ?? {})
603
+ .filter(Boolean)
604
+ .map((value) => value.id ?? value),
605
+ sidecar_workflow: result
606
+ },
607
+ ["get_selected_context", "get_component_status"]
608
+ );
609
+ }
610
+ },
611
+ {
612
+ name: "sidecar_replace_clip_notes",
613
+ description:
614
+ "Apply a note payload through the optional Max for Live sidecar, or return setup instructions if it is unavailable.",
615
+ inputSchema: createObjectSchema({
616
+ properties: {
617
+ clipId: {
618
+ type: "string",
619
+ description: "Canonical clip id such as clip:session:track:8:slot:1."
620
+ },
621
+ notes: {
622
+ type: "array",
623
+ items: noteItemSchema,
624
+ description: "Note payload to apply to the clip."
625
+ },
626
+ dryRun: dryRunProperty
627
+ },
628
+ required: ["clipId", "notes"]
629
+ }),
630
+ async execute(args) {
631
+ requireString(args.clipId, "clipId");
632
+ requireNotes(args.notes);
633
+ await policyAdapter.assertAllowed("sidecar_replace_clip_notes", args);
634
+ const before = await stateAdapter.getProjectSummary();
635
+ const replaced = await sidecarAdapter.replaceClipNotes({
636
+ clipId: args.clipId,
637
+ notes: args.notes,
638
+ dryRun: Boolean(args.dryRun)
639
+ });
640
+ const after = await stateAdapter.refreshState("project");
641
+ return buildMutationResult(
642
+ `Sidecar note replacement ${args.dryRun ? "previewed" : "applied"} for ${args.clipId}.`,
643
+ replaced.affectedObjects ?? [args.clipId],
644
+ before.stateVersion,
645
+ after.stateVersion,
646
+ after.warnings ?? []
647
+ );
648
+ }
649
+ },
650
+ {
651
+ name: "sidecar_observe_device_parameters",
652
+ description:
653
+ "Capture a selected-device parameter snapshot through the optional Max for Live sidecar, or return setup instructions if it is unavailable.",
654
+ inputSchema: createObjectSchema({
655
+ properties: {
656
+ trackId: {
657
+ type: "string",
658
+ description: "Optional track identifier when no track is selected in Live."
659
+ }
660
+ }
661
+ }),
662
+ async execute(args) {
663
+ const result = await sidecarAdapter.observeDeviceParameters({
664
+ trackId: args.trackId ?? null
665
+ });
666
+ return buildInformationalResult(
667
+ "Sidecar device parameter snapshot loaded.",
668
+ {
669
+ affected_objects: [
670
+ result.deviceTree?.trackId,
671
+ ...(result.deviceTree?.devices ?? []).map((device) => device.id)
672
+ ].filter(Boolean),
673
+ warnings: result.warnings ?? [],
674
+ sidecar_workflow: result
675
+ },
676
+ ["get_device_tree", "get_component_status"]
677
+ );
678
+ }
679
+ },
680
+ {
681
+ name: "run_sidecar_workflow",
682
+ description:
683
+ "Execute an optional Max for Live sidecar workflow, or return setup instructions if the sidecar is unavailable.",
684
+ inputSchema: buildWorkflowSchema(
685
+ "Sidecar workflow name, for example snapshotSelectionContext or replaceClipNotes."
686
+ ),
687
+ async execute(args) {
688
+ const result = await sidecarAdapter.executeWorkflow(args.name, args.parameters ?? {});
689
+ return buildInformationalResult(
690
+ `Sidecar workflow ${args.name} completed.`,
691
+ {
692
+ affected_objects: ["sidecar"],
693
+ sidecar_workflow: result
694
+ },
695
+ ["get_selected_context", "refresh_state"]
696
+ );
697
+ }
698
+ },
699
+ {
700
+ name: "list_ui_workflows",
701
+ description: "List optional UI-helper workflows and current availability.",
702
+ inputSchema: EMPTY_OBJECT_SCHEMA,
703
+ async execute() {
704
+ const result = await uiAutomationAdapter.listWorkflows();
705
+ return buildInformationalResult(
706
+ "UI workflow status loaded.",
707
+ {
708
+ affected_objects: ["ui_helper"],
709
+ ui_helper: result
710
+ },
711
+ ["run_ui_workflow", "get_component_status"]
712
+ );
713
+ }
714
+ },
715
+ {
716
+ name: "ui_capture_context",
717
+ description:
718
+ "Capture frontmost-app context through the optional UI helper, or return setup instructions if it is unavailable.",
719
+ inputSchema: EMPTY_OBJECT_SCHEMA,
720
+ async execute() {
721
+ const result = await uiAutomationAdapter.executeWorkflow("captureContext");
722
+ return buildInformationalResult(
723
+ "UI helper context captured.",
724
+ {
725
+ affected_objects: ["ui_helper"],
726
+ ui_workflow: result
727
+ },
728
+ ["get_component_status"]
729
+ );
730
+ }
731
+ },
732
+ {
733
+ name: "ui_focus_section",
734
+ description:
735
+ "Focus a named Live section through the optional UI helper, or return setup instructions if it is unavailable.",
736
+ inputSchema: createObjectSchema({
737
+ properties: {
738
+ sectionName: {
739
+ type: "string",
740
+ description: "Target Live section name."
741
+ }
742
+ },
743
+ required: ["sectionName"]
744
+ }),
745
+ async execute(args) {
746
+ requireString(args.sectionName, "sectionName");
747
+ const result = await uiAutomationAdapter.executeWorkflow("focusSection", {
748
+ sectionName: args.sectionName
749
+ });
750
+ return buildInformationalResult(
751
+ `UI helper focused ${args.sectionName}.`,
752
+ {
753
+ affected_objects: ["ui_helper"],
754
+ ui_workflow: result
755
+ },
756
+ ["get_component_status"]
757
+ );
758
+ }
759
+ },
760
+ {
761
+ name: "ui_browser_search_and_load",
762
+ description:
763
+ "Search Ableton's browser and trigger a load action through the optional UI helper, or return setup instructions if it is unavailable.",
764
+ inputSchema: createObjectSchema({
765
+ properties: {
766
+ query: {
767
+ type: "string",
768
+ description: "Browser search query."
769
+ }
770
+ },
771
+ required: ["query"]
772
+ }),
773
+ async execute(args) {
774
+ requireString(args.query, "query");
775
+ const result = await uiAutomationAdapter.executeWorkflow("browserSearchAndLoad", {
776
+ query: args.query
777
+ });
778
+ return buildInformationalResult(
779
+ `UI helper searched the browser for ${args.query}.`,
780
+ {
781
+ affected_objects: ["ui_helper"],
782
+ ui_workflow: result
783
+ },
784
+ ["get_component_status", "refresh_state"]
785
+ );
786
+ }
787
+ },
788
+ {
789
+ name: "ui_export_audio_video",
790
+ description:
791
+ "Open Ableton's Export Audio/Video dialog through the optional UI helper, or return setup instructions if it is unavailable.",
792
+ inputSchema: EMPTY_OBJECT_SCHEMA,
793
+ async execute() {
794
+ const result = await uiAutomationAdapter.executeWorkflow("exportAudioVideo");
795
+ return buildInformationalResult(
796
+ "UI helper opened the Export Audio/Video flow.",
797
+ {
798
+ affected_objects: ["ui_helper"],
799
+ ui_workflow: result
800
+ },
801
+ ["get_component_status"]
802
+ );
803
+ }
804
+ },
805
+ {
806
+ name: "ui_export_with_preset",
807
+ description:
808
+ "Apply an export preset through the optional UI helper, or return setup instructions if it is unavailable.",
809
+ inputSchema: createObjectSchema({
810
+ properties: {
811
+ presetName: {
812
+ type: "string",
813
+ description: "Preset name to enter in the export dialog."
814
+ },
815
+ outputPath: {
816
+ type: "string",
817
+ description: "Output folder to enter in the export dialog."
818
+ }
819
+ },
820
+ required: ["presetName", "outputPath"]
821
+ }),
822
+ async execute(args) {
823
+ requireString(args.presetName, "presetName");
824
+ requireString(args.outputPath, "outputPath");
825
+ const result = await uiAutomationAdapter.executeWorkflow("exportWithPreset", {
826
+ presetName: args.presetName,
827
+ outputPath: args.outputPath
828
+ });
829
+ return buildInformationalResult(
830
+ `UI helper staged export preset ${args.presetName}.`,
831
+ {
832
+ affected_objects: ["ui_helper"],
833
+ ui_workflow: result
834
+ },
835
+ ["get_component_status"]
836
+ );
837
+ }
838
+ },
839
+ {
840
+ name: "run_ui_workflow",
841
+ description:
842
+ "Execute an optional UI-helper workflow, or return setup instructions if the UI helper is unavailable.",
843
+ inputSchema: buildWorkflowSchema(
844
+ "UI workflow name, for example exportAudioVideo, browserSearchAndLoad, or captureContext."
845
+ ),
846
+ async execute(args) {
847
+ const result = await uiAutomationAdapter.executeWorkflow(args.name, args.parameters ?? {});
848
+ return buildInformationalResult(
849
+ `UI workflow ${args.name} completed.`,
850
+ {
851
+ affected_objects: ["ui_helper"],
852
+ ui_workflow: result
853
+ },
854
+ ["get_component_status", "refresh_state"]
855
+ );
856
+ }
857
+ },
219
858
  {
220
859
  name: "refresh_state",
221
860
  description: "Force a state refresh for a target scope.",
861
+ inputSchema: createObjectSchema({
862
+ properties: {
863
+ target: {
864
+ type: "string",
865
+ description: "Refresh scope, for example `project`, `song`, or `track:7`."
866
+ }
867
+ }
868
+ }),
222
869
  async execute(args) {
223
870
  const target = args.target ?? "project";
224
871
  const refreshed = await stateAdapter.refreshState(target);
@@ -236,16 +883,27 @@ export function buildDefaultTools({ stateAdapter, bridgeAdapter, policyAdapter }
236
883
  {
237
884
  name: "get_capabilities",
238
885
  description: "Return bridge and server capabilities.",
886
+ inputSchema: EMPTY_OBJECT_SCHEMA,
239
887
  async execute() {
240
- const capabilities = await bridgeAdapter.getCapabilities();
888
+ const [capabilities, sidecarStatus, uiHelperStatus] = await Promise.all([
889
+ bridgeAdapter.getCapabilities(),
890
+ sidecarAdapter.getStatus(),
891
+ uiAutomationAdapter.getStatus()
892
+ ]);
241
893
  return {
242
894
  summary: "Capabilities loaded.",
243
895
  affected_objects: ["bridge", "server"],
244
896
  state_version_before: null,
245
897
  state_version_after: null,
246
898
  warnings: [],
247
- next_suggested_actions: ["get_project_summary"],
248
- capabilities
899
+ next_suggested_actions: ["get_project_summary", "get_component_status"],
900
+ capabilities: {
901
+ ...capabilities,
902
+ optional_components: {
903
+ sidecar: sidecarStatus,
904
+ ui_helper: uiHelperStatus
905
+ }
906
+ }
249
907
  };
250
908
  }
251
909
  }
@@ -8,3 +8,7 @@ export {
8
8
  createBridgeAdapter,
9
9
  createStateAdapter
10
10
  } from "./session.js";
11
+ export {
12
+ createSidecarAdapter,
13
+ createUiAutomationAdapter
14
+ } from "./optional-adapters.js";
@@ -0,0 +1,193 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import {
4
+ getDefaultSidecarInstallTarget
5
+ } from "../../live-sidecar-m4l/src/project.js";
6
+ import {
7
+ listWorkflows as listSidecarWorkflows
8
+ } from "../../live-sidecar-m4l/src/workflows.js";
9
+ import { executeWorkflow as executeUiWorkflow } from "../../ui-automation/src/executor.js";
10
+ import { getStableUiHelperInstallPaths } from "../../ui-automation/src/helper.js";
11
+ import { workflows as uiWorkflows } from "../../ui-automation/src/workflows.js";
12
+
13
+ import { McpServerError } from "./errors.js";
14
+
15
+ function buildSidecarSetupInstructions(devicePath) {
16
+ return [
17
+ "Run `npx laive-mcp install --apply` if the sidecar device is not installed yet.",
18
+ `Load \`${devicePath}\` onto a MIDI track in the current Ableton Live set.`,
19
+ "Keep the `laive` Control Surface enabled in Live before retrying the sidecar tool."
20
+ ];
21
+ }
22
+
23
+ function buildUiHelperSetupInstructions(appBundleRoot) {
24
+ return [
25
+ "Run `npx laive-mcp install --apply` if the UI helper app is not installed yet.",
26
+ `In macOS System Settings > Privacy & Security > Accessibility, add and enable \`${appBundleRoot}\`.`,
27
+ "Bring Ableton Live to the foreground before retrying the UI automation tool."
28
+ ];
29
+ }
30
+
31
+ function summarizeUiWorkflows() {
32
+ return Object.values(uiWorkflows).map((workflow) => ({
33
+ name: workflow.name,
34
+ description: workflow.description,
35
+ parameters: workflow.parameters ?? []
36
+ }));
37
+ }
38
+
39
+ function createSetupRequiredError(message, data) {
40
+ return new McpServerError("setup_required", message, data);
41
+ }
42
+
43
+ function requireConfigured(status, label) {
44
+ if (!status.configured) {
45
+ throw createSetupRequiredError(`${label} is not configured`, status);
46
+ }
47
+ }
48
+
49
+ function getStatus() {
50
+ const sidecarTarget = getDefaultSidecarInstallTarget();
51
+ const uiHelperTarget = getStableUiHelperInstallPaths();
52
+
53
+ return {
54
+ sidecar: {
55
+ configured: existsSync(sidecarTarget.devicePath),
56
+ devicePath: sidecarTarget.devicePath,
57
+ workflows: listSidecarWorkflows(),
58
+ setup_instructions: buildSidecarSetupInstructions(sidecarTarget.devicePath)
59
+ },
60
+ ui_helper: {
61
+ configured: existsSync(uiHelperTarget.appBundleRoot),
62
+ appBundleRoot: uiHelperTarget.appBundleRoot,
63
+ executablePath: uiHelperTarget.executablePath,
64
+ workflows: summarizeUiWorkflows(),
65
+ setup_instructions: buildUiHelperSetupInstructions(uiHelperTarget.appBundleRoot)
66
+ }
67
+ };
68
+ }
69
+
70
+ export function createSidecarAdapter({ stateAdapter, bridgeAdapter } = {}) {
71
+ return {
72
+ async getStatus() {
73
+ return getStatus().sidecar;
74
+ },
75
+ async listWorkflows() {
76
+ const status = getStatus();
77
+ return {
78
+ ...status.sidecar,
79
+ workflows: listSidecarWorkflows()
80
+ };
81
+ },
82
+ async snapshotSelectionContext() {
83
+ const status = getStatus();
84
+ requireConfigured(status.sidecar, "Max for Live sidecar");
85
+ if (!stateAdapter) {
86
+ throw new McpServerError("adapter_unavailable", "state adapter is not configured");
87
+ }
88
+ const context = await stateAdapter.getSelectedContext();
89
+ return {
90
+ workflow: "snapshotSelectionContext",
91
+ configured: true,
92
+ context
93
+ };
94
+ },
95
+ async replaceClipNotes({ clipId, notes, dryRun = false }) {
96
+ const status = getStatus();
97
+ requireConfigured(status.sidecar, "Max for Live sidecar");
98
+ if (!bridgeAdapter) {
99
+ throw new McpServerError("adapter_unavailable", "bridge adapter is not configured");
100
+ }
101
+ return await bridgeAdapter.insertNotes({
102
+ clipId,
103
+ notes,
104
+ dryRun
105
+ });
106
+ },
107
+ async observeDeviceParameters({ trackId } = {}) {
108
+ const status = getStatus();
109
+ requireConfigured(status.sidecar, "Max for Live sidecar");
110
+ if (!stateAdapter) {
111
+ throw new McpServerError("adapter_unavailable", "state adapter is not configured");
112
+ }
113
+ const context = await stateAdapter.getSelectedContext();
114
+ const resolvedTrackId = trackId ?? context.track?.id;
115
+ if (!resolvedTrackId) {
116
+ throw new McpServerError(
117
+ "invalid_request",
118
+ "trackId is required when no track is selected in Live"
119
+ );
120
+ }
121
+
122
+ return {
123
+ workflow: "observeDeviceParameters",
124
+ configured: true,
125
+ mode: "snapshot",
126
+ warnings: [
127
+ "Continuous sidecar event streaming is not yet emitted over MCP; returning a current parameter snapshot instead."
128
+ ],
129
+ selectedDeviceId: context.device?.id ?? null,
130
+ deviceTree: await stateAdapter.getDeviceTree(resolvedTrackId)
131
+ };
132
+ },
133
+ async executeWorkflow(name, parameters = {}) {
134
+ switch (name) {
135
+ case "snapshotSelectionContext":
136
+ return await this.snapshotSelectionContext();
137
+ case "replaceClipNotes":
138
+ return await this.replaceClipNotes({
139
+ clipId: parameters.clipId,
140
+ notes: parameters.notes,
141
+ dryRun: Boolean(parameters.dryRun)
142
+ });
143
+ case "observeDeviceParameters":
144
+ return await this.observeDeviceParameters({
145
+ trackId: parameters.trackId
146
+ });
147
+ default:
148
+ throw new McpServerError("invalid_request", `Unknown sidecar workflow: ${name}`);
149
+ }
150
+ }
151
+ };
152
+ }
153
+
154
+ export function createUiAutomationAdapter() {
155
+ return {
156
+ async getStatus() {
157
+ return getStatus().ui_helper;
158
+ },
159
+ async listWorkflows() {
160
+ const status = getStatus();
161
+ return {
162
+ ...status.ui_helper,
163
+ workflows: summarizeUiWorkflows()
164
+ };
165
+ },
166
+ async executeWorkflow(name, parameters = {}) {
167
+ const status = getStatus();
168
+ requireConfigured(status.ui_helper, "UI helper");
169
+ return {
170
+ workflow: name,
171
+ configured: true,
172
+ helper: {
173
+ appBundleRoot: status.ui_helper.appBundleRoot,
174
+ executablePath: status.ui_helper.executablePath
175
+ },
176
+ result: await executeUiWorkflow(name, parameters)
177
+ };
178
+ }
179
+ };
180
+ }
181
+
182
+ export function createIntegrationStatusAdapter({ sidecarAdapter, uiAutomationAdapter } = {}) {
183
+ return {
184
+ async getStatus() {
185
+ return {
186
+ sidecar: sidecarAdapter ? await sidecarAdapter.getStatus() : getStatus().sidecar,
187
+ ui_helper: uiAutomationAdapter
188
+ ? await uiAutomationAdapter.getStatus()
189
+ : getStatus().ui_helper
190
+ };
191
+ }
192
+ };
193
+ }
@@ -2,15 +2,41 @@ import rootPackage from "../../../package.json" with { type: "json" };
2
2
  import { ToolRegistry } from "./tool-registry.js";
3
3
  import { buildDefaultTools } from "./default-tools.js";
4
4
  import { McpServerError, toErrorShape } from "./errors.js";
5
+ import {
6
+ createIntegrationStatusAdapter,
7
+ createSidecarAdapter,
8
+ createUiAutomationAdapter
9
+ } from "./optional-adapters.js";
5
10
 
6
11
  export class LaiveMcpServer {
7
- constructor({ stateAdapter, bridgeAdapter, policyAdapter, serverInfo } = {}) {
12
+ constructor({
13
+ stateAdapter,
14
+ bridgeAdapter,
15
+ policyAdapter,
16
+ sidecarAdapter,
17
+ uiAutomationAdapter,
18
+ integrationStatusAdapter,
19
+ serverInfo
20
+ } = {}) {
8
21
  this.serverInfo = serverInfo ?? {
9
22
  name: "laive-mcp",
10
23
  version: rootPackage.version
11
24
  };
12
25
  this.stateAdapter = stateAdapter ?? createUnsupportedAdapter("state");
13
26
  this.bridgeAdapter = bridgeAdapter ?? createUnsupportedAdapter("bridge");
27
+ this.sidecarAdapter =
28
+ sidecarAdapter ??
29
+ createSidecarAdapter({
30
+ stateAdapter: this.stateAdapter,
31
+ bridgeAdapter: this.bridgeAdapter
32
+ });
33
+ this.uiAutomationAdapter = uiAutomationAdapter ?? createUiAutomationAdapter();
34
+ this.integrationStatusAdapter =
35
+ integrationStatusAdapter ??
36
+ createIntegrationStatusAdapter({
37
+ sidecarAdapter: this.sidecarAdapter,
38
+ uiAutomationAdapter: this.uiAutomationAdapter
39
+ });
14
40
  this.policyAdapter = policyAdapter ?? {
15
41
  async assertAllowed() {
16
42
  return true;
@@ -21,7 +47,10 @@ export class LaiveMcpServer {
21
47
  for (const tool of buildDefaultTools({
22
48
  stateAdapter: this.stateAdapter,
23
49
  bridgeAdapter: this.bridgeAdapter,
24
- policyAdapter: this.policyAdapter
50
+ policyAdapter: this.policyAdapter,
51
+ sidecarAdapter: this.sidecarAdapter,
52
+ uiAutomationAdapter: this.uiAutomationAdapter,
53
+ integrationStatusAdapter: this.integrationStatusAdapter
25
54
  })) {
26
55
  this.tools.register(tool);
27
56
  }
@@ -200,6 +200,40 @@ export function createBridgeAdapter(target) {
200
200
  affectedObjects: result.track ? [result.track.id] : []
201
201
  };
202
202
  },
203
+ async playTransport(options = {}) {
204
+ const bridgeClient = await resolveBridgeClient(target);
205
+ return (
206
+ await bridgeClient.request("call", "transport.play", {}, {
207
+ dryRun: Boolean(options.dryRun)
208
+ })
209
+ ).result;
210
+ },
211
+ async stopTransport(options = {}) {
212
+ const bridgeClient = await resolveBridgeClient(target);
213
+ return (
214
+ await bridgeClient.request("call", "transport.stop", {}, {
215
+ dryRun: Boolean(options.dryRun)
216
+ })
217
+ ).result;
218
+ },
219
+ async createScene(name = null, options = {}) {
220
+ const bridgeClient = await resolveBridgeClient(target);
221
+ const result = (
222
+ await bridgeClient.request(
223
+ "call",
224
+ "create_scene",
225
+ {
226
+ name
227
+ },
228
+ { dryRun: Boolean(options.dryRun) }
229
+ )
230
+ ).result;
231
+
232
+ return {
233
+ ...result,
234
+ affectedObjects: result.scene ? [result.scene.id] : ["scenes"]
235
+ };
236
+ },
203
237
  async createClip(payload) {
204
238
  const bridgeClient = await resolveBridgeClient(target);
205
239
  const result = (
@@ -221,6 +255,31 @@ export function createBridgeAdapter(target) {
221
255
  affectedObjects: result.clip ? [payload.trackId, result.clip.id] : [payload.trackId]
222
256
  };
223
257
  },
258
+ async insertNotes(payload, options = {}) {
259
+ const bridgeClient = await resolveBridgeClient(target);
260
+ const result = (
261
+ await bridgeClient.request(
262
+ "call",
263
+ "insert_notes",
264
+ {
265
+ clip_id: payload.clipId,
266
+ notes: (payload.notes ?? []).map((note) => ({
267
+ pitch: note.pitch,
268
+ start_beats: note.startBeats ?? note.start_beats,
269
+ duration_beats: note.durationBeats ?? note.duration_beats,
270
+ velocity: note.velocity,
271
+ mute: note.mute ?? false
272
+ }))
273
+ },
274
+ { dryRun: Boolean(options.dryRun ?? payload.dryRun) }
275
+ )
276
+ ).result;
277
+
278
+ return {
279
+ ...result,
280
+ affectedObjects: [payload.clipId]
281
+ };
282
+ },
224
283
  async setParameter(payload, options = {}) {
225
284
  const bridgeClient = await resolveBridgeClient(target);
226
285
  const result = (
@@ -6,6 +6,7 @@ import argparse
6
6
  import json
7
7
  import shutil
8
8
  import sys
9
+ import time
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
  from typing import Iterable, List
@@ -57,6 +58,21 @@ def ensure_source_exists(source_root: Path = REMOTE_SCRIPT_SOURCE) -> Path:
57
58
  return source_root
58
59
 
59
60
 
61
+ def remove_tree(path: Path, attempts: int = 3, delay_seconds: float = 0.05) -> None:
62
+ last_error = None
63
+ for _attempt in range(attempts):
64
+ try:
65
+ shutil.rmtree(path)
66
+ return
67
+ except FileNotFoundError:
68
+ return
69
+ except OSError as error:
70
+ last_error = error
71
+ time.sleep(delay_seconds)
72
+ if last_error is not None:
73
+ raise last_error
74
+
75
+
60
76
  def stage_remote_script(
61
77
  source_root: Path = REMOTE_SCRIPT_SOURCE,
62
78
  artifacts_dir: Path = DEFAULT_ARTIFACTS_DIR,
@@ -68,7 +84,7 @@ def stage_remote_script(
68
84
  target_dir = staging_root / source_root.name
69
85
 
70
86
  if target_dir.exists():
71
- shutil.rmtree(target_dir)
87
+ remove_tree(target_dir)
72
88
 
73
89
  shutil.copytree(source_root, target_dir)
74
90
  archive_path = shutil.make_archive(