livepilot 1.26.0 → 1.26.2

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 (191) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +1 -1
  3. package/installer/codex.js +87 -9
  4. package/livepilot/.Codex-plugin/plugin.json +8 -0
  5. package/livepilot/.claude-plugin/plugin.json +8 -0
  6. package/livepilot/.mcp.json +8 -0
  7. package/livepilot/agents/livepilot-producer/AGENT.md +314 -0
  8. package/livepilot/commands/arrange.md +47 -0
  9. package/livepilot/commands/beat.md +81 -0
  10. package/livepilot/commands/evaluate.md +51 -0
  11. package/livepilot/commands/memory.md +22 -0
  12. package/livepilot/commands/mix.md +47 -0
  13. package/livepilot/commands/perform.md +42 -0
  14. package/livepilot/commands/session.md +13 -0
  15. package/livepilot/commands/sounddesign.md +58 -0
  16. package/livepilot/rubrics/default_preset_check.md +82 -0
  17. package/livepilot/rubrics/layer_accumulation.md +79 -0
  18. package/livepilot/rubrics/layer_precision.md +79 -0
  19. package/livepilot/rubrics/modulation_presence.md +63 -0
  20. package/livepilot/rubrics/sound_design_depth.md +40 -0
  21. package/livepilot/skills/livepilot-arrangement/SKILL.md +164 -0
  22. package/livepilot/skills/livepilot-composition-engine/SKILL.md +151 -0
  23. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +97 -0
  24. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +102 -0
  25. package/livepilot/skills/livepilot-core/SKILL.md +261 -0
  26. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  27. package/livepilot/skills/livepilot-core/references/affordances/_schema.md +160 -0
  28. package/livepilot/skills/livepilot-core/references/affordances/devices/auto-filter.yaml +133 -0
  29. package/livepilot/skills/livepilot-core/references/affordances/devices/chorus-ensemble.yaml +91 -0
  30. package/livepilot/skills/livepilot-core/references/affordances/devices/compressor.yaml +98 -0
  31. package/livepilot/skills/livepilot-core/references/affordances/devices/convolution-reverb.yaml +113 -0
  32. package/livepilot/skills/livepilot-core/references/affordances/devices/corpus.yaml +84 -0
  33. package/livepilot/skills/livepilot-core/references/affordances/devices/drift.yaml +105 -0
  34. package/livepilot/skills/livepilot-core/references/affordances/devices/echo.yaml +108 -0
  35. package/livepilot/skills/livepilot-core/references/affordances/devices/eq-eight.yaml +95 -0
  36. package/livepilot/skills/livepilot-core/references/affordances/devices/glue-compressor.yaml +88 -0
  37. package/livepilot/skills/livepilot-core/references/affordances/devices/granulator-iii.yaml +104 -0
  38. package/livepilot/skills/livepilot-core/references/affordances/devices/hybrid-reverb.yaml +83 -0
  39. package/livepilot/skills/livepilot-core/references/affordances/devices/operator.yaml +98 -0
  40. package/livepilot/skills/livepilot-core/references/affordances/devices/ping-pong-delay.yaml +104 -0
  41. package/livepilot/skills/livepilot-core/references/affordances/devices/poli.yaml +98 -0
  42. package/livepilot/skills/livepilot-core/references/affordances/devices/saturator.yaml +98 -0
  43. package/livepilot/skills/livepilot-core/references/affordances/devices/shifter.yaml +77 -0
  44. package/livepilot/skills/livepilot-core/references/affordances/devices/simpler.yaml +113 -0
  45. package/livepilot/skills/livepilot-core/references/affordances/devices/utility.yaml +95 -0
  46. package/livepilot/skills/livepilot-core/references/affordances/devices/vinyl-distortion.yaml +92 -0
  47. package/livepilot/skills/livepilot-core/references/affordances/devices/wavetable.yaml +98 -0
  48. package/livepilot/skills/livepilot-core/references/artist-vocabularies.md +389 -0
  49. package/livepilot/skills/livepilot-core/references/automation-atlas.md +272 -0
  50. package/livepilot/skills/livepilot-core/references/concepts/_schema.md +158 -0
  51. package/livepilot/skills/livepilot-core/references/concepts/artists/akufen.yaml +116 -0
  52. package/livepilot/skills/livepilot-core/references/concepts/artists/aphex-twin.yaml +133 -0
  53. package/livepilot/skills/livepilot-core/references/concepts/artists/arca-sophie.yaml +131 -0
  54. package/livepilot/skills/livepilot-core/references/concepts/artists/autechre.yaml +130 -0
  55. package/livepilot/skills/livepilot-core/references/concepts/artists/basic-channel.yaml +140 -0
  56. package/livepilot/skills/livepilot-core/references/concepts/artists/basinski.yaml +126 -0
  57. package/livepilot/skills/livepilot-core/references/concepts/artists/boards-of-canada.yaml +124 -0
  58. package/livepilot/skills/livepilot-core/references/concepts/artists/burial.yaml +127 -0
  59. package/livepilot/skills/livepilot-core/references/concepts/artists/com-truise-tycho.yaml +121 -0
  60. package/livepilot/skills/livepilot-core/references/concepts/artists/daft-punk.yaml +117 -0
  61. package/livepilot/skills/livepilot-core/references/concepts/artists/dj-premier-rza.yaml +119 -0
  62. package/livepilot/skills/livepilot-core/references/concepts/artists/gas.yaml +134 -0
  63. package/livepilot/skills/livepilot-core/references/concepts/artists/hawtin.yaml +127 -0
  64. package/livepilot/skills/livepilot-core/references/concepts/artists/isolee-luomo.yaml +130 -0
  65. package/livepilot/skills/livepilot-core/references/concepts/artists/j-dilla.yaml +133 -0
  66. package/livepilot/skills/livepilot-core/references/concepts/artists/jeff-mills.yaml +120 -0
  67. package/livepilot/skills/livepilot-core/references/concepts/artists/johannsson-richter.yaml +132 -0
  68. package/livepilot/skills/livepilot-core/references/concepts/artists/madlib.yaml +124 -0
  69. package/livepilot/skills/livepilot-core/references/concepts/artists/moodymann-theo-parrish.yaml +121 -0
  70. package/livepilot/skills/livepilot-core/references/concepts/artists/oneohtrix-point-never.yaml +126 -0
  71. package/livepilot/skills/livepilot-core/references/concepts/artists/photek-source-direct.yaml +120 -0
  72. package/livepilot/skills/livepilot-core/references/concepts/artists/rashad-spinn-traxman.yaml +122 -0
  73. package/livepilot/skills/livepilot-core/references/concepts/artists/robert-henke.yaml +113 -0
  74. package/livepilot/skills/livepilot-core/references/concepts/artists/shackleton.yaml +124 -0
  75. package/livepilot/skills/livepilot-core/references/concepts/artists/skream-mala.yaml +119 -0
  76. package/livepilot/skills/livepilot-core/references/concepts/artists/stars-of-the-lid.yaml +119 -0
  77. package/livepilot/skills/livepilot-core/references/concepts/artists/tim-hecker.yaml +122 -0
  78. package/livepilot/skills/livepilot-core/references/concepts/artists/villalobos.yaml +135 -0
  79. package/livepilot/skills/livepilot-core/references/concepts/genres/ambient.yaml +137 -0
  80. package/livepilot/skills/livepilot-core/references/concepts/genres/boom_bap.yaml +124 -0
  81. package/livepilot/skills/livepilot-core/references/concepts/genres/deep-minimal.yaml +130 -0
  82. package/livepilot/skills/livepilot-core/references/concepts/genres/deep_house.yaml +130 -0
  83. package/livepilot/skills/livepilot-core/references/concepts/genres/detroit_techno.yaml +116 -0
  84. package/livepilot/skills/livepilot-core/references/concepts/genres/disco.yaml +123 -0
  85. package/livepilot/skills/livepilot-core/references/concepts/genres/downtempo.yaml +129 -0
  86. package/livepilot/skills/livepilot-core/references/concepts/genres/drone.yaml +133 -0
  87. package/livepilot/skills/livepilot-core/references/concepts/genres/drum-and-bass.yaml +119 -0
  88. package/livepilot/skills/livepilot-core/references/concepts/genres/dub-techno.yaml +132 -0
  89. package/livepilot/skills/livepilot-core/references/concepts/genres/dub.yaml +129 -0
  90. package/livepilot/skills/livepilot-core/references/concepts/genres/dubstep.yaml +120 -0
  91. package/livepilot/skills/livepilot-core/references/concepts/genres/experimental.yaml +136 -0
  92. package/livepilot/skills/livepilot-core/references/concepts/genres/footwork.yaml +119 -0
  93. package/livepilot/skills/livepilot-core/references/concepts/genres/hip-hop.yaml +132 -0
  94. package/livepilot/skills/livepilot-core/references/concepts/genres/house.yaml +126 -0
  95. package/livepilot/skills/livepilot-core/references/concepts/genres/hyperpop.yaml +128 -0
  96. package/livepilot/skills/livepilot-core/references/concepts/genres/idm.yaml +134 -0
  97. package/livepilot/skills/livepilot-core/references/concepts/genres/lo_fi.yaml +129 -0
  98. package/livepilot/skills/livepilot-core/references/concepts/genres/microhouse.yaml +138 -0
  99. package/livepilot/skills/livepilot-core/references/concepts/genres/minimal-techno.yaml +116 -0
  100. package/livepilot/skills/livepilot-core/references/concepts/genres/modern-classical.yaml +123 -0
  101. package/livepilot/skills/livepilot-core/references/concepts/genres/soul.yaml +125 -0
  102. package/livepilot/skills/livepilot-core/references/concepts/genres/synthwave.yaml +123 -0
  103. package/livepilot/skills/livepilot-core/references/concepts/genres/techno.yaml +123 -0
  104. package/livepilot/skills/livepilot-core/references/concepts/genres/trap.yaml +120 -0
  105. package/livepilot/skills/livepilot-core/references/concepts/genres/uk-garage.yaml +121 -0
  106. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
  107. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
  108. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
  109. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
  110. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
  111. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
  112. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
  113. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
  114. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
  115. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
  116. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  117. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  118. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  119. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  120. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  121. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  124. package/livepilot/skills/livepilot-core/references/genre-vocabularies.md +382 -0
  125. package/livepilot/skills/livepilot-core/references/m4l-devices.md +352 -0
  126. package/livepilot/skills/livepilot-core/references/memory-guide.md +178 -0
  127. package/livepilot/skills/livepilot-core/references/midi-recipes.md +402 -0
  128. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +578 -0
  129. package/livepilot/skills/livepilot-core/references/overview.md +300 -0
  130. package/livepilot/skills/livepilot-core/references/pack-knowledge.md +319 -0
  131. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  132. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  133. package/livepilot/skills/livepilot-core/references/sound-design.md +393 -0
  134. package/livepilot/skills/livepilot-corpus-builder/SKILL.md +379 -0
  135. package/livepilot/skills/livepilot-creative-director/SKILL.md +462 -0
  136. package/livepilot/skills/livepilot-creative-director/references/anti-repetition-rules.md +214 -0
  137. package/livepilot/skills/livepilot-creative-director/references/creative-brief-template.md +222 -0
  138. package/livepilot/skills/livepilot-creative-director/references/hybrid-compilation.md +185 -0
  139. package/livepilot/skills/livepilot-creative-director/references/move-family-diversity-rule.md +258 -0
  140. package/livepilot/skills/livepilot-creative-director/references/phase-6-execution.md +409 -0
  141. package/livepilot/skills/livepilot-creative-director/references/the-four-move-rule.md +192 -0
  142. package/livepilot/skills/livepilot-devices/SKILL.md +213 -0
  143. package/livepilot/skills/livepilot-devices/references/load_browser_item-uri-grammar.md +82 -0
  144. package/livepilot/skills/livepilot-evaluation/SKILL.md +195 -0
  145. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +176 -0
  146. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +121 -0
  147. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +110 -0
  148. package/livepilot/skills/livepilot-mix-engine/SKILL.md +144 -0
  149. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +143 -0
  150. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +105 -0
  151. package/livepilot/skills/livepilot-mixing/SKILL.md +164 -0
  152. package/livepilot/skills/livepilot-notes/SKILL.md +130 -0
  153. package/livepilot/skills/livepilot-performance-engine/SKILL.md +122 -0
  154. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +98 -0
  155. package/livepilot/skills/livepilot-release/SKILL.md +151 -0
  156. package/livepilot/skills/livepilot-sample-engine/SKILL.md +117 -0
  157. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  158. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  159. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  160. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +247 -0
  161. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +119 -0
  162. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +118 -0
  163. package/livepilot/skills/livepilot-wonder/SKILL.md +143 -0
  164. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  165. package/m4l_device/LivePilot_Elektron.amxd +0 -0
  166. package/m4l_device/LivePilot_Elektron.maxpat +758 -0
  167. package/m4l_device/livepilot_bridge.js +1 -1
  168. package/m4l_device/livepilot_elektron_bridge.js +82 -0
  169. package/mcp_server/__init__.py +1 -1
  170. package/mcp_server/composer/develop/apply.py +1 -1
  171. package/mcp_server/composer/full/apply.py +32 -6
  172. package/mcp_server/composer/full/brief_builder.py +9 -0
  173. package/mcp_server/evaluation/feature_extractors.py +152 -8
  174. package/mcp_server/m4l_bridge.py +5 -0
  175. package/mcp_server/mix_engine/state_builder.py +19 -2
  176. package/mcp_server/mix_engine/tools.py +22 -0
  177. package/mcp_server/runtime/execution_router.py +6 -0
  178. package/mcp_server/runtime/mcp_dispatch.py +18 -0
  179. package/mcp_server/runtime/remote_commands.py +2 -0
  180. package/mcp_server/server.py +11 -7
  181. package/mcp_server/sound_design/tools.py +33 -0
  182. package/mcp_server/tools/_agent_os_engine/evaluation.py +7 -44
  183. package/mcp_server/tools/_agent_os_engine/models.py +2 -1
  184. package/mcp_server/tools/_conductor.py +5 -2
  185. package/mcp_server/tools/_evaluation_contracts.py +1 -1
  186. package/mcp_server/tools/_snapshot_normalizer.py +32 -3
  187. package/package.json +20 -5
  188. package/remote_script/LivePilot/__init__.py +1 -1
  189. package/remote_script/LivePilot/server.py +63 -2
  190. package/requirements.txt +3 -3
  191. package/server.json +3 -3
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
34
34
  // Single source of truth for the bridge version — bumped alongside the
35
35
  // rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
36
36
  // so the frozen .amxd visibly reports which build it was last exported from.
37
- var VERSION = "1.26.0";
37
+ var VERSION = "1.26.2";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -0,0 +1,82 @@
1
+ // m4l_device/livepilot_elektron_bridge.js
2
+ // LivePilot Elektron Bridge — runs inside LivePilot_Elektron.amxd.
3
+ //
4
+ // Wire model:
5
+ //
6
+ // M4L → Python : UDP 9882
7
+ // Inbound SysEx from physical MIDI [sysexin → sxformat
8
+ // → prepend sysex] arrives at JS as a "sysex <bytes...>"
9
+ // message; forwards out outlet 1 (→ [udpsend 9882]).
10
+ //
11
+ // Python → M4L : UDP 9883
12
+ // [udpreceive 9883] outputs bytes as a "list" message
13
+ // → JS function list() pushes onto sendQueue.
14
+ // [metro 50 @active 1] drives bang() which drains one
15
+ // chunk per tick onto outlet 0 (→ [midiout]).
16
+ //
17
+ // Outlets:
18
+ // 0 → [midiout] paced chunks to physical MIDI port
19
+ // 1 → [udpsend 127.0.0.1 9882] sysex-from-device upstream to Python
20
+ // 2 → "set <text>" → live.text status display
21
+ // 3 → +1 counter → live.numbox RX message counter
22
+ //
23
+ // Phase 1 design note: heartbeat is intentionally OUT for v1 of the M4L
24
+ // device. The Python bridge uses per-operation timeouts (per design
25
+ // spec §7.3) so it does not need M4L liveness polling. Heartbeat / pong
26
+ // can be added in a Phase 2 patch to the .maxpat if "is M4L loaded?"
27
+ // detection becomes useful — the ping()/pong() functions below are
28
+ // kept as reserved stubs for that future wiring.
29
+
30
+ inlets = 1;
31
+ outlets = 4;
32
+
33
+ var sendQueue = [];
34
+ var rxCount = 0;
35
+
36
+ // Called by [metro 50 @active 1] — drain one queued chunk per tick.
37
+ function bang() {
38
+ if (sendQueue.length > 0) {
39
+ var chunk = sendQueue.shift();
40
+ // Spread chunk into outlet args so Max sees it as a list message
41
+ outlet.apply(this, [0].concat(chunk));
42
+ }
43
+ }
44
+
45
+ // Called by [udpreceive 9883] — outputs raw UDP bytes as a list.
46
+ // This is the Python → M4L outbound MIDI path. Each datagram is a
47
+ // pre-chunked SysEx fragment; we queue and let bang() pace them.
48
+ function list() {
49
+ var bytes = [];
50
+ for (var i = 0; i < arguments.length; i++) {
51
+ bytes.push(arguments[i]);
52
+ }
53
+ sendQueue.push(bytes);
54
+ }
55
+
56
+ // Called when [sysexin → sxformat → prepend sysex] delivers a complete
57
+ // SysEx as a "sysex <bytes...>" message. We forward to Python and
58
+ // bump the RX counter + status.
59
+ function sysex() {
60
+ var bytes = [];
61
+ for (var i = 0; i < arguments.length; i++) {
62
+ bytes.push(arguments[i]);
63
+ }
64
+ // Forward to Python via outlet 1 → [udpsend 9882]
65
+ outlet.apply(this, [1].concat(bytes));
66
+ // Bump RX counter via outlet 3
67
+ rxCount += 1;
68
+ outlet(3, rxCount);
69
+ // Update status via outlet 2
70
+ outlet(2, "set", "Online — receiving SysEx");
71
+ }
72
+
73
+ // Phase 2 reserved: heartbeat ping. Wire [metro 1000] → [t ping] → js
74
+ // to enable. Not wired in Phase 1 .maxpat.
75
+ function ping() {
76
+ outlet(1, 0xF0, 0x7F, 0x7F, 0x00, 0xF7);
77
+ }
78
+
79
+ // Phase 2 reserved: receive pong from Python. Currently unused.
80
+ function pong() {
81
+ outlet(2, "set", "Online — pong received");
82
+ }
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.26.0"
2
+ __version__ = "1.26.2"
@@ -64,7 +64,7 @@ async def _bridge_ping_stub(ctx: Any) -> dict:
64
64
  bridge = ctx.lifespan_context.get("m4l_bridge")
65
65
  if bridge is None:
66
66
  raise RuntimeError("bridge not available")
67
- return await bridge.send_command("ping", {"timeout": 0.5})
67
+ return await bridge.send_command("ping", timeout=0.5)
68
68
 
69
69
 
70
70
  async def _back_to_arranger(ctx: Any) -> dict:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import re as _re
7
+ import inspect
7
8
  import time
8
9
 
9
10
  from fastmcp import Context
@@ -92,6 +93,28 @@ _TONAL_ROLES_ALWAYS_WARP: frozenset[str] = frozenset({
92
93
  })
93
94
 
94
95
 
96
+ async def _call_mcp_analysis_tool(ctx: Context, tool: str, params: dict) -> dict:
97
+ """Dispatch analyzer/intelligence tools through the MCP registry.
98
+
99
+ These tools are Python MCP tools, not Remote Script TCP handlers. Keeping
100
+ full-mode analysis on the registry path prevents creative plans from
101
+ passing tests against mocks and then failing in Live as unknown commands.
102
+ """
103
+ lifespan = getattr(ctx, "lifespan_context", {}) or {}
104
+ registry = lifespan.get("mcp_dispatch")
105
+ if registry is None:
106
+ from ...runtime.mcp_dispatch import build_mcp_dispatch_registry
107
+ registry = build_mcp_dispatch_registry()
108
+
109
+ fn = registry.get(tool) if registry else None
110
+ if fn is None:
111
+ return {"error": f"MCP analysis tool '{tool}' is not registered"}
112
+
113
+ call = fn(params, ctx=ctx)
114
+ result = await call if inspect.isawaitable(call) else call
115
+ return result if isinstance(result, dict) else {"result": result}
116
+
117
+
95
118
  def _decide_warp_loops(
96
119
  role: str,
97
120
  file_path: str,
@@ -825,8 +848,9 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
825
848
  # Goal: give the agent acoustic characteristics of the loaded sound so
826
849
  # it can reason about fit. ONLY static analysis here (no playback);
827
850
  # active solo-trigger analysis is Scope B / v1.25.
828
- # Analysis is routed through ableton.send_command so it is intercepted
829
- # by the same mock contract as all other commands (testable, consistent).
851
+ # Analysis is MCP-side intelligence, not a Remote Script TCP command.
852
+ # Route through the MCP dispatch registry so live execution matches
853
+ # the same boundary the async plan router uses.
830
854
  role = track_spec.get("role", "")
831
855
  instrument_uri = (track_spec.get("instrument") or {}).get("uri", "")
832
856
  layer_analysis: dict = {"status": "skipped", "reason": "no analyzer applicable"}
@@ -834,7 +858,8 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
834
858
  if instrument_uri.startswith(("query:Synths#", "query:Sounds#")):
835
859
  # Synth / preset — analyze the patch
836
860
  try:
837
- patch_result = ableton.send_command(
861
+ patch_result = await _call_mcp_analysis_tool(
862
+ ctx,
838
863
  "analyze_synth_patch",
839
864
  {"track_index": track_index, "device_index": 0},
840
865
  )
@@ -854,7 +879,8 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
854
879
  any(instrument_uri.lower().endswith(ext) for ext in (".aif", ".wav", ".mp3", ".flac")):
855
880
  # Sample-based — analyze via track reference (no file_path needed)
856
881
  try:
857
- sample_result = ableton.send_command(
882
+ sample_result = await _call_mcp_analysis_tool(
883
+ ctx,
858
884
  "analyze_sample",
859
885
  {"track_index": track_index, "clip_index": 0},
860
886
  )
@@ -1034,10 +1060,10 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
1034
1060
  # session state.
1035
1061
  mix_analysis: dict = {"status": "skipped", "reason": "not run"}
1036
1062
  try:
1037
- mix_result = ableton.send_command("analyze_mix", {})
1063
+ mix_result = await _call_mcp_analysis_tool(ctx, "analyze_mix", {})
1038
1064
  if isinstance(mix_result, dict) and not mix_result.get("error"):
1039
1065
  try:
1040
- masking_result = ableton.send_command("get_masking_report", {})
1066
+ masking_result = await _call_mcp_analysis_tool(ctx, "get_masking_report", {})
1041
1067
  except Exception as mask_exc:
1042
1068
  masking_result = {"error": str(mask_exc)}
1043
1069
  mix_analysis = {
@@ -47,6 +47,15 @@ _DESIGN_TARGETS = (
47
47
  "moves to schedule at chosen phrase boundaries. For niche style references "
48
48
  "in research_hooks, run WebSearch to ground your form choices in the "
49
49
  "actual conventions of that subgenre.\n\n"
50
+ "CHARACTER-FIRST NORMAL MODE:\n"
51
+ "Do not spend full mode on long level-balancing loops. Producers can adjust "
52
+ "simple volume by ear; your high-value job is to choose instruments, sources, "
53
+ "device chains, macro states, envelopes, filters, saturation, modulation, "
54
+ "and structural reveals that fit the requested character. Use analyzer/mix "
55
+ "feedback as evidence and safety, but prefer timbral/source decisions over "
56
+ "`set_track_volume`, `set_track_pan`, or broad send tweaking unless the "
57
+ "brief explicitly asks for mix balance, loudness, headroom, stereo translation, "
58
+ "or masking repair.\n\n"
50
59
  "INSTRUMENT SELECTION (v1.25 hybrid knowledge surface — MANDATED FOUR-SOURCE SEARCH):\n"
51
60
  "The brief's `atlas_anchors` is ONE source. Before committing any role pick "
52
61
  "you MUST also query the other three sources below. Factory-atlas-only picks "
@@ -9,7 +9,7 @@ All returned values are clamped to 0.0-1.0 for consistent scoring.
9
9
  from __future__ import annotations
10
10
 
11
11
  import math
12
- from typing import Optional
12
+ from typing import Any, Optional
13
13
 
14
14
  from ..tools._evaluation_contracts import MEASURABLE_DIMENSIONS
15
15
 
@@ -19,6 +19,42 @@ def _clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
19
19
  return max(lo, min(hi, value))
20
20
 
21
21
 
22
+ def _number(value: Any) -> Optional[float]:
23
+ """Best-effort numeric coercion for analyzer payloads."""
24
+ if isinstance(value, bool):
25
+ return float(value)
26
+ if isinstance(value, (int, float)):
27
+ return float(value)
28
+ return None
29
+
30
+
31
+ def _nested_number(payload: Any, *keys: str) -> Optional[float]:
32
+ """Read a numeric value from a dict payload using candidate keys."""
33
+ if isinstance(payload, dict):
34
+ for key in keys:
35
+ value = _number(payload.get(key))
36
+ if value is not None:
37
+ return value
38
+ return _number(payload)
39
+
40
+
41
+ def _centroid_to_unit(centroid: float) -> float:
42
+ """Map centroid-like values to 0..1.
43
+
44
+ FluCoMa deployments may report centroid in Hz or normalized units.
45
+ Values <= 1 are treated as normalized. Larger values are mapped across
46
+ a practical musical range where 150 Hz is very dark and 8 kHz is bright.
47
+ """
48
+ if centroid <= 1.0:
49
+ return _clamp(centroid)
50
+ return _clamp((centroid - 150.0) / (8000.0 - 150.0))
51
+
52
+
53
+ def _lufs_to_unit(lufs: float) -> float:
54
+ """Map momentary LUFS to a rough 0..1 energy proxy."""
55
+ return _clamp((lufs + 60.0) / 60.0)
56
+
57
+
22
58
  def extract_dimension_value(
23
59
  snapshot: dict,
24
60
  dimension: str,
@@ -37,31 +73,49 @@ def extract_dimension_value(
37
73
  if not snapshot or not isinstance(snapshot, dict):
38
74
  return None
39
75
 
40
- bands = snapshot.get("spectrum")
41
- if not bands:
42
- return None
76
+ bands = snapshot.get("spectrum") or {}
77
+ spectral_shape = snapshot.get("spectral_shape") or {}
78
+ onset = snapshot.get("onset") or {}
79
+ novelty = snapshot.get("novelty") or {}
80
+ loudness = snapshot.get("loudness") or {}
43
81
 
44
82
  rms = snapshot.get("rms")
45
83
  peak = snapshot.get("peak")
46
84
 
47
85
  if dimension == "brightness":
86
+ centroid = _nested_number(spectral_shape, "centroid", "centroid_hz")
87
+ if centroid is not None:
88
+ return _centroid_to_unit(centroid)
89
+ if not bands:
90
+ return None
48
91
  high = bands.get("high", 0)
49
92
  presence = bands.get("presence", 0)
50
93
  return _clamp((high + presence) / 2.0)
51
94
 
52
95
  elif dimension == "warmth":
96
+ if not bands:
97
+ return None
53
98
  return _clamp(bands.get("low_mid", 0))
54
99
 
55
100
  elif dimension == "weight":
56
- sub = bands.get("sub", 0)
101
+ if not bands:
102
+ return None
103
+ sub = bands.get("sub_low", bands.get("sub", 0))
57
104
  low = bands.get("low", 0)
58
105
  return _clamp((sub + low) / 2.0)
59
106
 
60
107
  elif dimension == "clarity":
108
+ if not bands:
109
+ return None
61
110
  low_mid = bands.get("low_mid", 0)
62
111
  return _clamp(1.0 - low_mid)
63
112
 
64
113
  elif dimension == "density":
114
+ flatness = _nested_number(spectral_shape, "flatness", "spectral_flatness")
115
+ if flatness is not None:
116
+ return _clamp(flatness)
117
+ if not bands:
118
+ return None
65
119
  vals = [max(v, 1e-10) for v in bands.values()
66
120
  if isinstance(v, (int, float))]
67
121
  if not vals:
@@ -71,14 +125,104 @@ def extract_dimension_value(
71
125
  return _clamp(geo_mean / max(arith_mean, 1e-10))
72
126
 
73
127
  elif dimension == "energy":
74
- return _clamp(rms) if rms is not None else None
128
+ rms_value = _number(rms)
129
+ if rms_value is not None:
130
+ return _clamp(rms_value)
131
+ lufs = _nested_number(loudness, "momentary_lufs", "lufs", "integrated_lufs")
132
+ if lufs is not None:
133
+ return _lufs_to_unit(lufs)
134
+ return None
75
135
 
76
136
  elif dimension == "punch":
77
- if rms and peak and rms > 0:
78
- crest_db = 20.0 * math.log10(max(peak / rms, 1.0))
137
+ rms_value = _number(rms)
138
+ peak_value = _number(peak)
139
+ if rms_value and peak_value and rms_value > 0:
140
+ crest_db = 20.0 * math.log10(max(peak_value / rms_value, 1.0))
79
141
  return _clamp(crest_db / 20.0)
142
+ onset_strength = _nested_number(onset, "strength", "onset")
143
+ if onset_strength is not None:
144
+ return _clamp(onset_strength)
145
+ spectral_crest = _nested_number(spectral_shape, "crest")
146
+ if spectral_crest is not None:
147
+ return _clamp(spectral_crest)
148
+ return None
149
+
150
+ elif dimension == "novelty":
151
+ novelty_score = _nested_number(novelty, "score", "novelty", "value")
152
+ return _clamp(novelty_score) if novelty_score is not None else None
153
+
154
+ elif dimension == "motion":
155
+ novelty_score = _nested_number(novelty, "score", "novelty", "value")
156
+ onset_strength = _nested_number(onset, "strength", "onset")
157
+ vals = [v for v in (novelty_score, onset_strength) if v is not None]
158
+ if vals:
159
+ return _clamp(sum(vals) / len(vals))
80
160
  return None
81
161
 
82
162
  else:
83
163
  # Unmeasurable dimension
84
164
  return None
165
+
166
+
167
+ def _label_low_mid_high(value: float, low: str, mid: str, high: str) -> str:
168
+ if value < 0.33:
169
+ return low
170
+ if value > 0.67:
171
+ return high
172
+ return mid
173
+
174
+
175
+ def extract_character_profile(snapshot: dict) -> dict:
176
+ """Summarize analyzer data as a production-oriented character profile.
177
+
178
+ This is intentionally descriptive, not prescriptive. Engines can attach
179
+ the profile to their analysis response so the agent chooses sound-source,
180
+ device, and parameter moves before reaching for generic level changes.
181
+ """
182
+ if not snapshot or not isinstance(snapshot, dict):
183
+ return {"available": False, "values": {}, "labels": {}, "biases": []}
184
+
185
+ dimensions = (
186
+ "brightness", "warmth", "weight", "clarity", "density",
187
+ "energy", "punch", "motion", "novelty",
188
+ )
189
+ values = {
190
+ dim: round(val, 4)
191
+ for dim in dimensions
192
+ if (val := extract_dimension_value(snapshot, dim)) is not None
193
+ }
194
+
195
+ labels: dict[str, str] = {}
196
+ if "brightness" in values:
197
+ labels["brightness"] = _label_low_mid_high(values["brightness"], "dark", "balanced", "bright")
198
+ if "warmth" in values:
199
+ labels["warmth"] = _label_low_mid_high(values["warmth"], "lean", "warm", "thick")
200
+ if "weight" in values:
201
+ labels["weight"] = _label_low_mid_high(values["weight"], "light", "grounded", "heavy")
202
+ if "density" in values:
203
+ labels["density"] = _label_low_mid_high(values["density"], "peaked", "shaped", "flat/noisy")
204
+ if "punch" in values:
205
+ labels["punch"] = _label_low_mid_high(values["punch"], "soft", "defined", "spiky")
206
+ if "motion" in values:
207
+ labels["motion"] = _label_low_mid_high(values["motion"], "static", "moving", "busy")
208
+
209
+ biases: list[str] = []
210
+ if values.get("brightness", 0.5) > 0.72:
211
+ biases.append("prefer filter tone, source choice, or de-harshing over lowering track volume")
212
+ if values.get("brightness", 0.5) < 0.28:
213
+ biases.append("prefer oscillator/filter opening, excitation, or air-band source choice over level boosts")
214
+ if values.get("motion", 0.5) < 0.25:
215
+ biases.append("prefer modulation, envelope drift, or evolving devices before static mix moves")
216
+ if values.get("punch", 0.5) < 0.25:
217
+ biases.append("prefer envelope/transient shaping or source layering before pushing volume")
218
+ if values.get("density", 0.0) > 0.75:
219
+ biases.append("prefer subtractive filtering or simpler source selection when the spectrum is flat/noisy")
220
+ if values.get("weight", 0.5) < 0.25:
221
+ biases.append("prefer instrument/register/source changes for low-end weight before master gain")
222
+
223
+ return {
224
+ "available": bool(values),
225
+ "values": values,
226
+ "labels": labels,
227
+ "biases": biases,
228
+ }
@@ -1014,6 +1014,11 @@ class M4LBridge:
1014
1014
  while len(s_bytes) % 4 != 0:
1015
1015
  s_bytes += b'\x00'
1016
1016
  arg_data += s_bytes
1017
+ else:
1018
+ raise TypeError(
1019
+ "OSC argument for %s must be int, float, or str, got %s"
1020
+ % (address, type(arg).__name__)
1021
+ )
1017
1022
 
1018
1023
  tag_bytes = type_tags.encode('ascii') + b'\x00'
1019
1024
  while len(tag_bytes) % 4 != 0:
@@ -68,6 +68,19 @@ def _name_signals_non_anchor(track_name: str) -> bool:
68
68
  # Frequency bands where masking is most problematic.
69
69
  _MASKING_BANDS = ("sub", "low", "low_mid", "mid", "high_mid", "presence", "high")
70
70
 
71
+ _MASKING_ROLE_ALIASES = {
72
+ "sub_bass": "bass",
73
+ "hihat": "percussion",
74
+ "hat": "percussion",
75
+ "clap": "percussion",
76
+ "snare": "percussion",
77
+ }
78
+
79
+
80
+ def _masking_role(role: str) -> str:
81
+ """Normalize detailed track roles into the collision-rule vocabulary."""
82
+ return _MASKING_ROLE_ALIASES.get(role, role)
83
+
71
84
 
72
85
  # ── Balance ─────────────────────────────────────────────────────────
73
86
 
@@ -164,7 +177,7 @@ def build_masking_map(
164
177
  # Build role->indices mapping
165
178
  role_to_indices: dict[str, list[int]] = {}
166
179
  for idx, role in track_roles.items():
167
- role_to_indices.setdefault(role, []).append(idx)
180
+ role_to_indices.setdefault(_masking_role(role), []).append(idx)
168
181
 
169
182
  # Known problematic role pairs and their collision bands
170
183
  collision_rules: list[tuple[str, str, str, float]] = [
@@ -267,7 +280,11 @@ def build_mix_state(
267
280
  role_hints = role_hints or {}
268
281
 
269
282
  balance = build_balance_state(track_infos, role_hints)
270
- masking = build_masking_map(spectrum, role_hints)
283
+ inferred_roles = {
284
+ track.track_index: role_hints.get(track.track_index, track.role)
285
+ for track in balance.track_states
286
+ }
287
+ masking = build_masking_map(spectrum, inferred_roles)
271
288
 
272
289
  # Extract peak from spectrum if available
273
290
  peak = None
@@ -11,6 +11,7 @@ from fastmcp import Context
11
11
  from ..server import mcp
12
12
  from ..tools._evaluation_contracts import EvaluationRequest
13
13
  from ..tools._snapshot_normalizer import normalize_sonic_snapshot
14
+ from ..evaluation.feature_extractors import extract_character_profile
14
15
  from ..evaluation.fabric import evaluate_sonic_move
15
16
  from .state_builder import build_mix_state
16
17
  from .critics import run_all_mix_critics
@@ -56,6 +57,18 @@ def _fetch_mix_data(ctx: Context) -> dict:
56
57
  rms_snap = spectral.get("rms")
57
58
  if rms_snap:
58
59
  rms_data = rms_snap["value"] if isinstance(rms_snap["value"], dict) else rms_snap["value"]
60
+ if spectrum is not None:
61
+ spectrum["rms"] = rms_data.get("rms") if isinstance(rms_data, dict) else rms_data
62
+ peak_snap = spectral.get("peak")
63
+ if peak_snap and spectrum is not None:
64
+ spectrum["peak"] = peak_snap["value"]
65
+
66
+ for key in ("spectral_shape", "mel_bands", "chroma", "onset", "novelty", "loudness"):
67
+ snap = spectral.get(key)
68
+ if snap:
69
+ if spectrum is None:
70
+ spectrum = {}
71
+ spectrum[key] = snap["value"]
59
72
  except Exception as exc:
60
73
  logger.debug("_fetch_mix_data failed: %s", exc)
61
74
 
@@ -86,9 +99,12 @@ def analyze_mix(ctx: Context) -> dict:
86
99
  )
87
100
  issues = run_all_mix_critics(mix_state)
88
101
  moves = plan_mix_moves(issues, mix_state)
102
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
103
+ sonic_character = extract_character_profile(sonic_snapshot or {})
89
104
 
90
105
  return {
91
106
  "mix_state": mix_state.to_dict(),
107
+ "sonic_character": sonic_character,
92
108
  "issues": [i.to_dict() for i in issues],
93
109
  "suggested_moves": [m.to_dict() for m in moves],
94
110
  "issue_count": len(issues),
@@ -110,8 +126,10 @@ def get_mix_issues(ctx: Context) -> dict:
110
126
  rms_data=data["rms_data"],
111
127
  )
112
128
  issues = run_all_mix_critics(mix_state)
129
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
113
130
 
114
131
  return {
132
+ "sonic_character": extract_character_profile(sonic_snapshot or {}),
115
133
  "issues": [i.to_dict() for i in issues],
116
134
  "issue_count": len(issues),
117
135
  }
@@ -133,8 +151,10 @@ def plan_mix_move(ctx: Context) -> dict:
133
151
  )
134
152
  issues = run_all_mix_critics(mix_state)
135
153
  moves = plan_mix_moves(issues, mix_state)
154
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
136
155
 
137
156
  return {
157
+ "sonic_character": extract_character_profile(sonic_snapshot or {}),
138
158
  "moves": [m.to_dict() for m in moves],
139
159
  "move_count": len(moves),
140
160
  "issue_count": len(issues),
@@ -212,10 +232,12 @@ def get_mix_summary(ctx: Context) -> dict:
212
232
  rms_data=data["rms_data"],
213
233
  )
214
234
  issues = run_all_mix_critics(mix_state)
235
+ sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
215
236
 
216
237
  return {
217
238
  "track_count": len(mix_state.balance.track_states),
218
239
  "issue_count": len(issues),
240
+ "sonic_character": extract_character_profile(sonic_snapshot or {}),
219
241
  "dynamics": mix_state.dynamics.to_dict(),
220
242
  "stereo": mix_state.stereo.to_dict(),
221
243
  "depth": mix_state.depth.to_dict(),
@@ -36,7 +36,10 @@ from .remote_commands import BRIDGE_COMMANDS, REMOTE_COMMANDS
36
36
  MCP_TOOLS: frozenset[str] = frozenset({
37
37
  "apply_automation_shape",
38
38
  "apply_gesture_template",
39
+ "analyze_sample",
40
+ "analyze_synth_patch",
39
41
  "analyze_mix",
42
+ "get_masking_report",
40
43
  "get_master_spectrum",
41
44
  "get_emotional_arc",
42
45
  "get_motif_graph",
@@ -92,7 +95,10 @@ READ_ONLY_TOOLS: frozenset[str] = frozenset({
92
95
  "get_cue_points",
93
96
  "get_rack_chains",
94
97
  "get_clip_automation",
98
+ "analyze_sample",
99
+ "analyze_synth_patch",
95
100
  "analyze_mix",
101
+ "get_masking_report",
96
102
  "get_emotional_arc",
97
103
  "get_motif_graph",
98
104
  "get_session_diagnostics",
@@ -60,11 +60,26 @@ async def _apply_gesture_template(params: dict, ctx: Any = None) -> dict:
60
60
  return await _call(apply_gesture_template, ctx, params)
61
61
 
62
62
 
63
+ async def _analyze_sample(params: dict, ctx: Any = None) -> dict:
64
+ from ..sample_engine.tools import analyze_sample
65
+ return await _call(analyze_sample, ctx, params)
66
+
67
+
68
+ async def _analyze_synth_patch(params: dict, ctx: Any = None) -> dict:
69
+ from ..synthesis_brain.tools import analyze_synth_patch
70
+ return await _call(analyze_synth_patch, ctx, params)
71
+
72
+
63
73
  async def _analyze_mix(params: dict, ctx: Any = None) -> dict:
64
74
  from ..mix_engine.tools import analyze_mix
65
75
  return await _call(analyze_mix, ctx, params)
66
76
 
67
77
 
78
+ async def _get_masking_report(params: dict, ctx: Any = None) -> dict:
79
+ from ..mix_engine.tools import get_masking_report
80
+ return await _call(get_masking_report, ctx, params)
81
+
82
+
68
83
  async def _get_master_spectrum(params: dict, ctx: Any = None) -> dict:
69
84
  from ..tools.analyzer import get_master_spectrum
70
85
  return await _call(get_master_spectrum, ctx, params)
@@ -151,7 +166,10 @@ def build_mcp_dispatch_registry() -> dict[str, Callable]:
151
166
  "load_sample_to_simpler": _load_sample_to_simpler,
152
167
  "apply_automation_shape": _apply_automation_shape,
153
168
  "apply_gesture_template": _apply_gesture_template,
169
+ "analyze_sample": _analyze_sample,
170
+ "analyze_synth_patch": _analyze_synth_patch,
154
171
  "analyze_mix": _analyze_mix,
172
+ "get_masking_report": _get_masking_report,
155
173
  "get_master_spectrum": _get_master_spectrum,
156
174
  "get_emotional_arc": _get_emotional_arc,
157
175
  "get_motif_graph": _get_motif_graph,
@@ -83,6 +83,8 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
83
83
  "capture_midi", "start_recording", "stop_recording",
84
84
  "get_cue_points", "jump_to_cue", "toggle_cue_point",
85
85
  "back_to_arranger", "force_arrangement",
86
+ "arrangement_automation_via_session_record_start",
87
+ "arrangement_automation_via_session_record_complete",
86
88
  # scales — Song + per-clip scale awareness (Live 12.0+)
87
89
  "get_song_scale", "set_song_scale", "set_song_scale_mode",
88
90
  "list_available_scales",
@@ -355,14 +355,14 @@ def _coerce_schema_property(prop: dict) -> None:
355
355
  def _get_all_tools():
356
356
  """Get all registered tools — defends against FastMCP internal drift.
357
357
 
358
- FastMCP's public API doesn't expose the registry as of 3.2.x (see
358
+ FastMCP's public API doesn't expose the registry as of 3.3.x (see
359
359
  docs/FASTMCP_UPSTREAM_FR.md). Until it does, we probe known internal
360
360
  attribute paths. Each probe fires in try/except so a structural
361
- rearrangement (e.g. ``_components`` renamed under 3.3+) falls through
361
+ rearrangement (e.g. ``_components`` renamed under 3.4+) falls through
362
362
  to the next path rather than exploding.
363
363
 
364
364
  WARNING: Accesses FastMCP private internals. Pinned to
365
- fastmcp>=3.0.0,<3.3.0 in requirements.txt. The startup self-test
365
+ fastmcp>=3.3.1,<3.4.0 in requirements.txt. The startup self-test
366
366
  (_assert_tool_registry_accessible) will fail loudly if every probe
367
367
  returns empty — better than silently returning [] and disabling
368
368
  schema coercion.
@@ -370,14 +370,18 @@ def _get_all_tools():
370
370
  probes = [
371
371
  # FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
372
372
  ("_tool_manager._tools", lambda: list(mcp._tool_manager._tools.values())),
373
- # FastMCP 3.0–3.2: mcp._local_provider._components
373
+ # FastMCP 3.0–3.3: mcp._local_provider._components
374
+ # (verified 2026-05-21 against fastmcp 3.3.1 — still the active path)
374
375
  (
375
376
  "_local_provider._components",
376
377
  lambda: list(mcp._local_provider._components.values()),
377
378
  ),
378
- # FastMCP 3.3+ speculative: mcp._local_provider._tools (anticipated
379
- # rename based on naming conventions in other providers). Kept here
380
- # so a future bump surfaces a partial match rather than a full miss.
379
+ # FastMCP 3.4+ speculative: mcp._local_provider._tools (anticipated
380
+ # rename based on naming conventions in other providers). Verified
381
+ # 2026-05-21 against fastmcp 3.3.1 the rename did NOT happen in
382
+ # 3.3.x; ``_local_provider._components`` remains the live registry.
383
+ # Kept here so a future bump that DOES rename surfaces a partial
384
+ # match rather than a full miss.
381
385
  (
382
386
  "_local_provider._tools",
383
387
  lambda: list(mcp._local_provider._tools.values()),