mxl-agent 0.1.0__py3-none-any.whl

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 (161) hide show
  1. mxl_agent/__init__.py +1 -0
  2. mxl_agent/adapters/__init__.py +5 -0
  3. mxl_agent/adapters/chord_text.py +89 -0
  4. mxl_agent/adapters/jjazzlab_sng.py +200 -0
  5. mxl_agent/adapters/mcp.py +246 -0
  6. mxl_agent/adapters/mei.py +59 -0
  7. mxl_agent/adapters/musescore.py +206 -0
  8. mxl_agent/adapters/render_identity.py +237 -0
  9. mxl_agent/adapters/timemap.py +77 -0
  10. mxl_agent/adapters/vendor/ext_apps_client.js +86 -0
  11. mxl_agent/adapters/verovio.py +154 -0
  12. mxl_agent/analysis/__init__.py +49 -0
  13. mxl_agent/analysis/_cross_part.py +71 -0
  14. mxl_agent/analysis/_harmony.py +141 -0
  15. mxl_agent/analysis/_key.py +58 -0
  16. mxl_agent/analysis/_time_signature.py +50 -0
  17. mxl_agent/analysis/_transposition.py +28 -0
  18. mxl_agent/analysis/active_key.py +74 -0
  19. mxl_agent/analysis/base.py +30 -0
  20. mxl_agent/analysis/cadence_candidate.py +84 -0
  21. mxl_agent/analysis/chord_recognition.py +101 -0
  22. mxl_agent/analysis/chord_tone.py +168 -0
  23. mxl_agent/analysis/doubling.py +108 -0
  24. mxl_agent/analysis/harmonic_rhythm.py +105 -0
  25. mxl_agent/analysis/interval_above_bass.py +137 -0
  26. mxl_agent/analysis/lint.py +249 -0
  27. mxl_agent/analysis/local_key.py +131 -0
  28. mxl_agent/analysis/non_chord_tone.py +196 -0
  29. mxl_agent/analysis/note_density.py +61 -0
  30. mxl_agent/analysis/parallel_motion.py +132 -0
  31. mxl_agent/analysis/playability.py +119 -0
  32. mxl_agent/analysis/polyphony.py +53 -0
  33. mxl_agent/analysis/registry.py +31 -0
  34. mxl_agent/analysis/roman_numeral.py +145 -0
  35. mxl_agent/analysis/run.py +22 -0
  36. mxl_agent/analysis/scale_degree.py +175 -0
  37. mxl_agent/analysis/syncopation.py +104 -0
  38. mxl_agent/analysis/voice_leading.py +113 -0
  39. mxl_agent/cli.py +1471 -0
  40. mxl_agent/document/mutation_guard.py +80 -0
  41. mxl_agent/document/score_document.py +83 -0
  42. mxl_agent/document/source_locator.py +45 -0
  43. mxl_agent/errors.py +29 -0
  44. mxl_agent/generate/__init__.py +5 -0
  45. mxl_agent/generate/accompaniment.py +236 -0
  46. mxl_agent/generate/arrangement.py +103 -0
  47. mxl_agent/generate/from_midi.py +392 -0
  48. mxl_agent/generate/lead_sheet.py +300 -0
  49. mxl_agent/generate/reharmonization.py +305 -0
  50. mxl_agent/generate/voicing.py +258 -0
  51. mxl_agent/midi/__init__.py +2 -0
  52. mxl_agent/midi/reader.py +232 -0
  53. mxl_agent/model/addresses.py +17 -0
  54. mxl_agent/model/chord_recognition.py +91 -0
  55. mxl_agent/model/chord_tones.py +145 -0
  56. mxl_agent/model/diff.py +34 -0
  57. mxl_agent/model/duration.py +169 -0
  58. mxl_agent/model/events.py +38 -0
  59. mxl_agent/model/harmony.py +561 -0
  60. mxl_agent/model/instrument_ranges.py +81 -0
  61. mxl_agent/model/key_signature.py +135 -0
  62. mxl_agent/model/layout.py +61 -0
  63. mxl_agent/model/merge.py +135 -0
  64. mxl_agent/model/metadata.py +36 -0
  65. mxl_agent/model/non_chord_tone.py +54 -0
  66. mxl_agent/model/pitch.py +177 -0
  67. mxl_agent/model/roman_numeral.py +300 -0
  68. mxl_agent/model/score_index.py +208 -0
  69. mxl_agent/model/selection.py +203 -0
  70. mxl_agent/model/semantic_diff.py +309 -0
  71. mxl_agent/model/timeline.py +299 -0
  72. mxl_agent/model/timewise.py +126 -0
  73. mxl_agent/operations/__init__.py +44 -0
  74. mxl_agent/operations/_key.py +71 -0
  75. mxl_agent/operations/_measure_attributes.py +84 -0
  76. mxl_agent/operations/_note_selection.py +120 -0
  77. mxl_agent/operations/_note_xml.py +117 -0
  78. mxl_agent/operations/_offset_position.py +65 -0
  79. mxl_agent/operations/_parts.py +44 -0
  80. mxl_agent/operations/_score_header.py +39 -0
  81. mxl_agent/operations/_xml_order.py +26 -0
  82. mxl_agent/operations/barlines.py +247 -0
  83. mxl_agent/operations/base.py +31 -0
  84. mxl_agent/operations/change_duration.py +234 -0
  85. mxl_agent/operations/chord_symbol_style.py +157 -0
  86. mxl_agent/operations/copy_notes.py +330 -0
  87. mxl_agent/operations/create_tuplet.py +343 -0
  88. mxl_agent/operations/credit.py +186 -0
  89. mxl_agent/operations/delete_note.py +172 -0
  90. mxl_agent/operations/direction_spanners.py +586 -0
  91. mxl_agent/operations/directions.py +393 -0
  92. mxl_agent/operations/envelope.py +67 -0
  93. mxl_agent/operations/harmony.py +339 -0
  94. mxl_agent/operations/insert_note.py +328 -0
  95. mxl_agent/operations/layout.py +321 -0
  96. mxl_agent/operations/lyrics.py +248 -0
  97. mxl_agent/operations/measure_operations.py +448 -0
  98. mxl_agent/operations/merge_tied_notes.py +228 -0
  99. mxl_agent/operations/metadata.py +299 -0
  100. mxl_agent/operations/move_note.py +313 -0
  101. mxl_agent/operations/noop.py +42 -0
  102. mxl_agent/operations/notations.py +422 -0
  103. mxl_agent/operations/notes.py +171 -0
  104. mxl_agent/operations/registry.py +28 -0
  105. mxl_agent/operations/rename_part.py +186 -0
  106. mxl_agent/operations/repair.py +326 -0
  107. mxl_agent/operations/respell.py +167 -0
  108. mxl_agent/operations/score_defaults.py +286 -0
  109. mxl_agent/operations/set_attributes.py +422 -0
  110. mxl_agent/operations/spanners.py +383 -0
  111. mxl_agent/operations/split_note.py +236 -0
  112. mxl_agent/operations/transpose.py +205 -0
  113. mxl_agent/package/__init__.py +0 -0
  114. mxl_agent/package/compare.py +96 -0
  115. mxl_agent/package/manifest.py +33 -0
  116. mxl_agent/package/reader.py +189 -0
  117. mxl_agent/package/safety.py +262 -0
  118. mxl_agent/package/writer.py +96 -0
  119. mxl_agent/patch/__init__.py +0 -0
  120. mxl_agent/patch/apply.py +51 -0
  121. mxl_agent/patch/inverse.py +88 -0
  122. mxl_agent/patch/model.py +136 -0
  123. mxl_agent/patch/plan.py +79 -0
  124. mxl_agent/patch/repair.py +44 -0
  125. mxl_agent/query/inspect.py +134 -0
  126. mxl_agent/query/pagination.py +90 -0
  127. mxl_agent/query/projection.py +101 -0
  128. mxl_agent/result.py +40 -0
  129. mxl_agent/service/__init__.py +13 -0
  130. mxl_agent/service/session.py +96 -0
  131. mxl_agent/session/__init__.py +0 -0
  132. mxl_agent/session/checkpoints.py +63 -0
  133. mxl_agent/session/revisions.py +92 -0
  134. mxl_agent/session/selection_store.py +47 -0
  135. mxl_agent/session/transaction.py +451 -0
  136. mxl_agent/session/workspace.py +272 -0
  137. mxl_agent/validation/__init__.py +0 -0
  138. mxl_agent/validation/compatibility.py +65 -0
  139. mxl_agent/validation/issue.py +15 -0
  140. mxl_agent/validation/package.py +29 -0
  141. mxl_agent/validation/preservation.py +80 -0
  142. mxl_agent/validation/relationships.py +113 -0
  143. mxl_agent/validation/reopen.py +34 -0
  144. mxl_agent/validation/report.py +70 -0
  145. mxl_agent/validation/schema.py +29 -0
  146. mxl_agent/validation/temporal.py +63 -0
  147. mxl_agent/viewer/__init__.py +10 -0
  148. mxl_agent/viewer/server.py +633 -0
  149. mxl_agent/viewer/static/index.html +210 -0
  150. mxl_agent/viewer/static/viewer.js +790 -0
  151. mxl_agent/xml/__init__.py +0 -0
  152. mxl_agent/xml/canonical.py +41 -0
  153. mxl_agent/xml/detect.py +35 -0
  154. mxl_agent/xml/parser.py +84 -0
  155. mxl_agent/xml/schema.py +140 -0
  156. mxl_agent-0.1.0.dist-info/METADATA +80 -0
  157. mxl_agent-0.1.0.dist-info/RECORD +161 -0
  158. mxl_agent-0.1.0.dist-info/WHEEL +4 -0
  159. mxl_agent-0.1.0.dist-info/entry_points.txt +2 -0
  160. mxl_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  161. mxl_agent-0.1.0.dist-info/licenses/NOTICE +84 -0
mxl_agent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Adapters over optional external tools (Section 8.2). Nothing in the core engine imports these
2
+ -- each is reached only through its own module, and only when the corresponding capability is
3
+ actually requested."""
4
+
5
+ from __future__ import annotations
@@ -0,0 +1,89 @@
1
+ """Chord-chart text adapters: a small, pluggable text grammar for typed chord-chart input
2
+ (Section 15.12, 20 Slice 8.2).
3
+
4
+ This slice implements only "a small documented generic dialect" -- the spec bullet's first half.
5
+ JJazzLab-oriented fixtures/dialect support is a documented follow-up, not implemented here.
6
+
7
+ Grammar (the "generic" dialect):
8
+
9
+ - Bars are separated by ``|``. A single leading and/or trailing ``|`` (an opening/closing
10
+ barline) is optional and stripped before splitting -- it does not itself produce an extra bar.
11
+ - Within a bar, chord symbols are separated by whitespace and are spaced evenly across the bar's
12
+ exact quarter-note length (Section 3.5's exact rational time, never a float): the *i*-th of
13
+ *n* tokens in a bar sits at offset ``i * measure_length / n``.
14
+ - An empty bar (no tokens between two ``|``) means no chord in that measure -- a deliberate rest/
15
+ unspecified bar, not an error.
16
+ - A bar containing exactly the single token ``%`` repeats the immediately preceding bar's chord
17
+ entries verbatim (the standard chart "simile" convention); it cannot be the chart's first bar,
18
+ and cannot be mixed with other tokens in the same bar.
19
+
20
+ This grammar does not itself validate chord-symbol *text* -- ``symbol`` is carried through as-is.
21
+ That mirrors ``generate.lead_sheet``'s own layering: its harmony entries aren't run through
22
+ ``model.harmony.parse_chord_symbol`` until they are actually materialized, keeping "chart
23
+ grammar" and "chord-symbol semantics" as two separately testable layers.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from fractions import Fraction
30
+
31
+ from mxl_agent.errors import MxlAgentError
32
+ from mxl_agent.model.duration import measure_quarter_length
33
+
34
+ __all__ = ["ChordChartParseError", "ChordChartEntry", "parse_generic_chord_chart"]
35
+
36
+
37
+ class ChordChartParseError(MxlAgentError):
38
+ code = "CHORD_CHART_PARSE_ERROR"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ChordChartEntry:
43
+ offset_q: Fraction
44
+ symbol: str
45
+
46
+
47
+ def _strip_outer_barlines(text: str) -> str:
48
+ stripped = text.strip()
49
+ if stripped.startswith("|"):
50
+ stripped = stripped[1:]
51
+ if stripped.endswith("|"):
52
+ stripped = stripped[:-1]
53
+ return stripped
54
+
55
+
56
+ def parse_generic_chord_chart(
57
+ text: str, *, beats: int, beat_type: int
58
+ ) -> tuple[tuple[ChordChartEntry, ...], ...]:
59
+ """Parse ``text`` (the generic dialect above) into one tuple of ``ChordChartEntry`` per bar,
60
+ in order. ``beats``/``beat_type`` give the time signature used to space each bar's tokens --
61
+ same meaning as ``model.duration.measure_quarter_length``."""
62
+ if not text.strip():
63
+ raise ChordChartParseError("chord chart text is empty")
64
+ measure_length = measure_quarter_length(beats, beat_type)
65
+
66
+ bars: list[tuple[ChordChartEntry, ...]] = []
67
+ body = _strip_outer_barlines(text)
68
+ for bar_index, raw_bar in enumerate(body.split("|")):
69
+ tokens = raw_bar.split()
70
+ if "%" in tokens:
71
+ if tokens != ["%"]:
72
+ raise ChordChartParseError(
73
+ f"bar {bar_index}: '%' must be the only token in its bar, got {tokens!r}"
74
+ )
75
+ if not bars:
76
+ raise ChordChartParseError(
77
+ f"bar {bar_index} uses '%' but there is no preceding bar to repeat"
78
+ )
79
+ bars.append(bars[-1])
80
+ continue
81
+
82
+ count = len(tokens)
83
+ bars.append(
84
+ tuple(
85
+ ChordChartEntry(offset_q=measure_length * i / count, symbol=token)
86
+ for i, token in enumerate(tokens)
87
+ )
88
+ )
89
+ return tuple(bars)
@@ -0,0 +1,200 @@
1
+ """JJazzLab ``.sng`` chord-chart import (Section 15.12, 20 Slice 8.2's JJazzLab residual,
2
+ Slice 11.13).
3
+
4
+ ``.sng`` is JJazzLab's own project file format: a custom (not raw-reflection XStream) XML
5
+ serialization of its internal song model, undocumented and not covered by any compatibility
6
+ guarantee -- field names could shift across JJazzLab versions. Reverse-engineered here against a
7
+ real corpus of ~1,460 ``.sng`` files (JJazzLab 4, each itself converted from iReal Pro charts via
8
+ MuseScore/MusicXML import), not just a single sample. Read-only and best-effort: this project
9
+ never authors ``.sng``, only extracts a chord leadsheet from an existing one.
10
+
11
+ Structure actually observed (the only part this adapter reads):
12
+
13
+ - ``<Song spName="..." spTempo="...">`` -- the whole project. ``spName``/``spTempo`` become
14
+ ``JJazzLabChart.name``/``.tempo``.
15
+ - ``<spChordLeadSheet><spItems>...</spItems><spSize>N</spSize></spChordLeadSheet>`` -- the chord
16
+ chart itself, ``N`` bars long (global bar count, not per-section). ``<spItems>`` interleaves:
17
+ - ``<CLI__SectionImpl spName="..." spTs="FOUR_FOUR" spBarIndex="8">`` -- a section starting at
18
+ a global bar index, carrying its own time signature (``spTs``: one of the seven enum values
19
+ in ``_TIME_SIGNATURES`` below, the complete set found across the whole corpus). A later
20
+ section's ``spTs`` applies from its ``spBarIndex`` onward, carried forward exactly like every
21
+ other "active X at this position" walk in this codebase (e.g. ``analysis._key.active_key_at``)
22
+ -- confirmed necessary, not just theoretical: several real files in the corpus do change time
23
+ signature mid-chart.
24
+ - ``<CLI__ChordSymbolImpl>`` wrapping ``<spChord spName="Fm7" .../>`` (the chord symbol text,
25
+ JJazzLab's own dialect, structurally the same vocabulary as a plain pop chord symbol -- slash
26
+ chords, altered tensions, etc.) and ``<spPos spPos="[bar:beat]"/>`` (a 0-indexed global bar
27
+ and a 0-indexed *beat* within that bar's own time signature -- confirmed against real files:
28
+ beat values only ever appear in the range the active time signature's beat count allows).
29
+ When a section repeats an earlier one with the exact same chord (common: an "A-2" section
30
+ reusing "A-1"'s chords), JJazzLab does not duplicate the ``<spChord>`` content -- it writes an
31
+ empty ``<spChord reference="../../CLI__ChordSymbolImpl[N]/spChord"/>`` pointing back at the
32
+ first real one instead (an XStream object-identity back-reference). Confirmed the *only*
33
+ reference shape in the corpus (``_CHORD_BACK_REFERENCE``); resolved via a validated,
34
+ restricted XPath lookup rather than executing the untrusted ``reference`` string outright.
35
+
36
+ Deliberately not read: ``<spSongStructure>``/``<SongPartImpl>`` (rhythm-style/playback assignment
37
+ -- a different concern from the chord chart itself), ``<spRenderingInfo>`` (display-only chord
38
+ symbol features like PEDAL_BASS), ``<spClientProperties>`` (arbitrary key/value extension data),
39
+ and the back-reference ``reference="../.."``-style XStream plumbing used for cyclic object graphs.
40
+ None of these affect the chord-chart content this adapter extracts.
41
+
42
+ Output deliberately mirrors ``adapters.chord_text.parse_generic_chord_chart``'s shape -- one tuple
43
+ of ``ChordChartEntry`` per bar, same dataclass -- so a parsed JJazzLab chart slots into the exact
44
+ same "copy into `create lead-sheet`'s `measures[].harmony` by hand" workflow Slice 8.2 already
45
+ established, rather than inventing a second, incompatible typed shape for the same kind of data.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import re
51
+ from dataclasses import dataclass
52
+ from fractions import Fraction
53
+
54
+ from lxml import etree
55
+
56
+ from mxl_agent.adapters.chord_text import ChordChartEntry
57
+ from mxl_agent.errors import MxlAgentError
58
+ from mxl_agent.model.duration import measure_quarter_length
59
+ from mxl_agent.xml.parser import parse_xml_document
60
+
61
+ # The one back-reference shape found across a ~1,460-file corpus (see below): a repeated chord
62
+ # object -- the same chord symbol reused verbatim, typically because a later section repeats an
63
+ # earlier one -- is serialized as an empty `<spChord reference="../../CLI__ChordSymbolImpl.../
64
+ # spChord"/>` pointing back at the first `<spChord>` with the real content, instead of duplicating
65
+ # it. Validated against this fixed pattern (rather than handed to `.xpath()` unchecked) since
66
+ # `reference` is untrusted input and arbitrary XPath is not a string this project executes blind.
67
+ _CHORD_BACK_REFERENCE = re.compile(r"^(?:\.\./)+CLI__ChordSymbolImpl(?:\[[1-9][0-9]*\])?/spChord$")
68
+
69
+ __all__ = ["JJazzLabSngParseError", "JJazzLabChart", "parse_jjazzlab_sng"]
70
+
71
+
72
+ class JJazzLabSngParseError(MxlAgentError):
73
+ code = "JJAZZLAB_SNG_PARSE_ERROR"
74
+
75
+
76
+ # The complete set of JJazzLab time-signature enum values found across a ~1,460-file corpus.
77
+ _TIME_SIGNATURES: dict[str, tuple[int, int]] = {
78
+ "TWO_FOUR": (2, 4),
79
+ "THREE_FOUR": (3, 4),
80
+ "FOUR_FOUR": (4, 4),
81
+ "FIVE_FOUR": (5, 4),
82
+ "SIX_FOUR": (6, 4),
83
+ "SIX_EIGHT": (6, 8),
84
+ "TWELVE_EIGHT": (12, 8),
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class JJazzLabChart:
90
+ name: str
91
+ tempo: int | None
92
+ bars: tuple[tuple[ChordChartEntry, ...], ...]
93
+
94
+
95
+ def _resolve_chord_element(spchord_el: etree._Element) -> etree._Element:
96
+ """Follow a repeated chord's back-``reference`` (see ``_CHORD_BACK_REFERENCE``) to the
97
+ ``<spChord>`` element that actually carries ``spName``; returns ``spchord_el`` unchanged if it
98
+ has no ``reference``."""
99
+ reference = spchord_el.get("reference")
100
+ if reference is None:
101
+ return spchord_el
102
+ if not _CHORD_BACK_REFERENCE.match(reference):
103
+ raise JJazzLabSngParseError(f"unrecognized <spChord> reference shape: {reference!r}")
104
+ targets = spchord_el.xpath(reference)
105
+ if not isinstance(targets, list) or not targets or not isinstance(targets[0], etree._Element):
106
+ raise JJazzLabSngParseError(f"<spChord> reference {reference!r} does not resolve")
107
+ return targets[0]
108
+
109
+
110
+ def _parse_position(raw: str, item_desc: str) -> tuple[int, int]:
111
+ body = raw[1:-1] if raw.startswith("[") and raw.endswith("]") else None
112
+ if body is None or ":" not in body:
113
+ raise JJazzLabSngParseError(f"{item_desc}: malformed position {raw!r}")
114
+ bar_text, _, beat_text = body.partition(":")
115
+ try:
116
+ return int(bar_text), int(beat_text)
117
+ except ValueError as exc:
118
+ raise JJazzLabSngParseError(f"{item_desc}: malformed position {raw!r}") from exc
119
+
120
+
121
+ def parse_jjazzlab_sng(data: bytes) -> JJazzLabChart:
122
+ """Extract a chord chart from a JJazzLab ``.sng`` project file's bytes."""
123
+ tree = parse_xml_document(data)
124
+ root = tree.getroot()
125
+ if root.tag != "Song":
126
+ raise JJazzLabSngParseError(f"expected a <Song> root element, got <{root.tag}>")
127
+
128
+ name = root.get("spName") or ""
129
+ tempo_text = root.get("spTempo")
130
+ tempo = int(tempo_text) if tempo_text is not None and tempo_text.isdigit() else None
131
+
132
+ leadsheet = root.find("spChordLeadSheet")
133
+ if leadsheet is None:
134
+ raise JJazzLabSngParseError("<Song> has no <spChordLeadSheet>")
135
+ items = leadsheet.find("spItems")
136
+ size_el = leadsheet.find("spSize")
137
+ if items is None or size_el is None or size_el.text is None:
138
+ raise JJazzLabSngParseError("<spChordLeadSheet> is missing <spItems> or <spSize>")
139
+ try:
140
+ size = int(size_el.text)
141
+ except ValueError as exc:
142
+ raise JJazzLabSngParseError(f"<spSize> is not an integer: {size_el.text!r}") from exc
143
+
144
+ sections: list[tuple[int, tuple[int, int]]] = []
145
+ for section_el in items.findall("CLI__SectionImpl"):
146
+ bar_index_text = section_el.get("spBarIndex")
147
+ ts_text = section_el.get("spTs")
148
+ if bar_index_text is None or ts_text is None:
149
+ raise JJazzLabSngParseError("<CLI__SectionImpl> is missing spBarIndex or spTs")
150
+ if ts_text not in _TIME_SIGNATURES:
151
+ raise JJazzLabSngParseError(f"unrecognized JJazzLab time signature {ts_text!r}")
152
+ try:
153
+ bar_index = int(bar_index_text)
154
+ except ValueError as exc:
155
+ raise JJazzLabSngParseError(
156
+ f"<CLI__SectionImpl> spBarIndex is not an integer: {bar_index_text!r}"
157
+ ) from exc
158
+ sections.append((bar_index, _TIME_SIGNATURES[ts_text]))
159
+ if not sections or sections[0][0] != 0:
160
+ raise JJazzLabSngParseError("chord leadsheet has no section starting at bar 0")
161
+ sections.sort(key=lambda entry: entry[0])
162
+
163
+ def _time_signature_at(bar_index: int) -> tuple[int, int]:
164
+ active = sections[0][1]
165
+ for start, time_signature in sections:
166
+ if start > bar_index:
167
+ break
168
+ active = time_signature
169
+ return active
170
+
171
+ entries_by_bar: dict[int, list[tuple[int, str]]] = {}
172
+ for chord_el in items.findall("CLI__ChordSymbolImpl"):
173
+ chord_ref = chord_el.find("spChord")
174
+ pos_ref = chord_el.find("spPos")
175
+ if chord_ref is None or pos_ref is None:
176
+ raise JJazzLabSngParseError("<CLI__ChordSymbolImpl> is missing spChord or spPos")
177
+ chord_ref = _resolve_chord_element(chord_ref)
178
+ symbol = chord_ref.get("spName")
179
+ pos_text = pos_ref.get("spPos")
180
+ if symbol is None or pos_text is None:
181
+ raise JJazzLabSngParseError("<CLI__ChordSymbolImpl> is missing spName or spPos")
182
+ bar_index, beat = _parse_position(pos_text, f"chord {symbol!r}")
183
+ if not (0 <= bar_index < size):
184
+ raise JJazzLabSngParseError(
185
+ f"chord {symbol!r} at bar {bar_index} is outside the chart's {size}-bar range"
186
+ )
187
+ entries_by_bar.setdefault(bar_index, []).append((beat, symbol))
188
+
189
+ bars: list[tuple[ChordChartEntry, ...]] = []
190
+ for bar_index in range(size):
191
+ beats, beat_type = _time_signature_at(bar_index)
192
+ quarter_per_beat = measure_quarter_length(beats, beat_type) / beats
193
+ raw_entries = sorted(entries_by_bar.get(bar_index, ()), key=lambda entry: entry[0])
194
+ bars.append(
195
+ tuple(
196
+ ChordChartEntry(offset_q=Fraction(beat) * quarter_per_beat, symbol=symbol)
197
+ for beat, symbol in raw_entries
198
+ )
199
+ )
200
+ return JJazzLabChart(name=name, tempo=tempo, bars=tuple(bars))
@@ -0,0 +1,246 @@
1
+ """MCP server adapter: exposes the Slice 10.1 `session` service as MCP tools, plus an embedded,
2
+ read-only score view through the MCP Apps extension (Section 15.10/15.13, 20 Slice 10.2/10.3).
3
+
4
+ Read-only tools first (Section 20's own MCP rollout order): every session tool here calls the
5
+ exact same ``mxl_agent.service.session`` functions the CLI's ``session open``/``status``/
6
+ ``checkout``/``revisions`` commands do -- "The MCP server delegates to the same application
7
+ service used by the CLI. It must not duplicate parsing or editing logic" (Section 15.10). None
8
+ of these tools mutate score content; ``checkout`` only moves a session's current-revision
9
+ pointer between revisions that already exist.
10
+
11
+ Every tool returns the *same* JSON envelope shape the CLI's ``--json`` output does
12
+ (``mxl_agent.result.build_envelope``) rather than raising on an expected failure -- one client
13
+ sees one contract whether it calls the CLI or MCP.
14
+
15
+ ``session_view`` additionally renders that score as an embedded "MCP App" (a still-evolving MCP
16
+ extension, specification dated 2026-01-26) -- a read-only Verovio SVG shown inline in a
17
+ compatible client, through ``mcp.server.apps.Apps`` and the vendored official client library
18
+ (``adapters/vendor/ext_apps_client.js``, see NOTICE). This project never reimplements that
19
+ extension's JSON-RPC-over-postMessage handshake itself (Section 22: "prefer reusing tested
20
+ machinery over re-deriving it"). Server-side registration and the tool's own structured output
21
+ are covered by this project's test suite; end-to-end rendering inside a real compatible host is
22
+ not, since no such host is available in this environment -- an explicit, documented limitation
23
+ (Section 21).
24
+
25
+ This module is the only place that imports the optional ``mcp`` package -- like
26
+ ``adapters.verovio``/``adapters.musescore``, it is entirely absent from the default install and
27
+ the core CLI/library never import it (CLAUDE.md: "Core behavior must work without Claude, a
28
+ browser, Verovio, or MuseScore").
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Callable
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from mxl_agent.adapters.verovio import render_score_page
38
+ from mxl_agent.errors import MxlAgentError
39
+ from mxl_agent.result import build_envelope
40
+ from mxl_agent.service import session as session_service
41
+ from mxl_agent.session.workspace import DEFAULT_WORKSPACE_ROOT, open_workspace
42
+
43
+ __all__ = ["McpUnavailableError", "build_server", "run_stdio_server", "mcp_availability"]
44
+
45
+ _SCORE_VIEW_RESOURCE_URI = "ui://mxl-agent/score-view"
46
+ _VENDORED_CLIENT_JS_PATH = Path(__file__).parent / "vendor" / "ext_apps_client.js"
47
+
48
+
49
+ class McpUnavailableError(MxlAgentError):
50
+ code = "MCP_UNAVAILABLE"
51
+
52
+
53
+ def _import_mcp_server_class() -> Any:
54
+ try:
55
+ from mcp.server.mcpserver import MCPServer
56
+ except ImportError:
57
+ return None
58
+ return MCPServer
59
+
60
+
61
+ def _import_apps_extension_class() -> Any:
62
+ try:
63
+ from mcp.server.apps import Apps
64
+ except ImportError:
65
+ return None
66
+ return Apps
67
+
68
+
69
+ def mcp_availability() -> dict[str, object]:
70
+ """``doctor``'s capability-detection payload for the optional MCP server (`mcp-serve`)."""
71
+ if _import_mcp_server_class() is None:
72
+ return {"available": False, "version": None}
73
+ from importlib.metadata import PackageNotFoundError, version
74
+
75
+ try:
76
+ installed_version = version("mcp")
77
+ except PackageNotFoundError:
78
+ installed_version = None
79
+ return {"available": True, "version": installed_version}
80
+
81
+
82
+ def _build_score_view_html() -> str:
83
+ """The ``ui://mxl-agent/score-view`` resource: the vendored MCP Apps client library, plus a
84
+ small amount of mxl-agent-authored initialization that connects to the host and renders
85
+ whatever SVG the ``session_view`` tool's ``structuredContent.svg`` carries."""
86
+ vendored_js = _VENDORED_CLIENT_JS_PATH.read_text(encoding="utf-8")
87
+ return f"""<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="UTF-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
+ <meta name="color-scheme" content="light dark">
93
+ <title>mxl-agent score view</title>
94
+ <style>
95
+ body {{ margin: 0; padding: 8px; font-family: system-ui, sans-serif; }}
96
+ #score svg {{ max-width: 100%; height: auto; }}
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <div id="score">Loading score...</div>
101
+ <script type="module">
102
+ {vendored_js}
103
+
104
+ // -- mxl-agent's own initialization (Slice 10.3), using the App class the vendored library
105
+ // -- above exposed via window.McpApp (see the comment at the end of that file).
106
+ const {{ App }} = window.McpApp;
107
+ const app = new App({{ name: "mxl-agent score view", version: "1.0.0" }});
108
+ const container = document.getElementById("score");
109
+
110
+ function render(result) {{
111
+ const content = result && result.structuredContent;
112
+ if (content && content.ok && content.svg) {{
113
+ container.innerHTML = content.svg;
114
+ }} else if (content && content.error) {{
115
+ container.textContent = "Could not render score: " + content.error.message;
116
+ }} else {{
117
+ container.textContent = "No score data received.";
118
+ }}
119
+ }}
120
+
121
+ app.ontoolresult = render;
122
+ app.onerror = (err) => {{ container.textContent = "Error: " + err; }};
123
+ app.connect().catch((err) => {{ container.textContent = "Failed to connect: " + err; }});
124
+ </script>
125
+ </body>
126
+ </html>"""
127
+
128
+
129
+ def _tool_result(command: str, fn: Callable[[], dict[str, Any]]) -> dict[str, Any]:
130
+ """Run ``fn``, returning the same envelope shape ``cli._run`` builds for ``--json`` output
131
+ -- an expected ``MxlAgentError`` becomes ``{"ok": false, "error": {...}}``, never a raised
132
+ exception the MCP transport would have to translate on our behalf."""
133
+ try:
134
+ result = fn()
135
+ return build_envelope(command, ok=True, **result)
136
+ except MxlAgentError as exc:
137
+ return build_envelope(command, ok=False, **exc.to_envelope_fields())
138
+
139
+
140
+ def build_server(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT) -> Any:
141
+ """Build (but do not run) the MCP server exposing the session tool family.
142
+
143
+ Raises ``McpUnavailableError`` if the optional ``mcp`` package isn't installed.
144
+ """
145
+ mcp_server_cls = _import_mcp_server_class()
146
+ if mcp_server_cls is None:
147
+ raise McpUnavailableError(
148
+ "the optional `mcp` package is not installed (install the `mcp` extra to enable this)"
149
+ )
150
+
151
+ # The Apps extension (Slice 10.3) is registered before the server itself is constructed --
152
+ # MCPServer takes its extensions at construction time. Older installs of the `mcp` package
153
+ # may lack this specific extension even when the base server class is present, so this is
154
+ # guarded separately: a missing Apps extension degrades to "no embedded score view", not to
155
+ # "no MCP server at all".
156
+ apps_extension_cls = _import_apps_extension_class()
157
+ extensions = None
158
+ if apps_extension_cls is not None:
159
+ apps_extension = apps_extension_cls()
160
+ apps_extension.add_html_resource(
161
+ _SCORE_VIEW_RESOURCE_URI,
162
+ _build_score_view_html(),
163
+ name="score-view",
164
+ title="Score view",
165
+ description="A read-only rendered view of the session's current score.",
166
+ )
167
+
168
+ @apps_extension.tool(resource_uri=_SCORE_VIEW_RESOURCE_URI) # type: ignore[untyped-decorator]
169
+ async def session_view(session_id: str, page: int = 1) -> dict[str, Any]:
170
+ """Render the session's current revision as an embedded score view (Section
171
+ 15.13, Slice 10.3), through the optional Verovio integration -- read-only.
172
+
173
+ Declared ``async`` (and calling Verovio directly, not via a thread pool) on purpose:
174
+ the MCP SDK dispatches plain ``def`` tool functions through ``anyio.to_thread``, and
175
+ Verovio's native toolkit has been observed to permanently break every later call in
176
+ the process once its first use happens on such a worker thread rather than the main
177
+ thread (see ROADMAP.md Slice 10.3). Blocking the event loop briefly here is an
178
+ acceptable trade-off for a single-client local stdio server (Section 15.10).
179
+ """
180
+
181
+ def _fn() -> dict[str, Any]:
182
+ ws = open_workspace(session_id, workspace_root=workspace_root)
183
+ svg = render_score_page(ws.read_working_bytes(), page=page)
184
+ return {"session_id": session_id, "page": page, "svg": svg}
185
+
186
+ return _tool_result("session_view", _fn)
187
+
188
+ extensions = [apps_extension]
189
+
190
+ server = mcp_server_cls(name="mxl-agent", extensions=extensions)
191
+
192
+ @server.tool() # type: ignore[untyped-decorator]
193
+ def session_open(input_path: str, score: str | None = None) -> dict[str, Any]:
194
+ """Open a .musicxml, .xml, or .mxl file into a new session at revision 0. `score`
195
+ selects which score to open when an .mxl package declares more than one."""
196
+
197
+ def _fn() -> dict[str, Any]:
198
+ data = Path(input_path).read_bytes()
199
+ return session_service.open_session(
200
+ data, workspace_root=workspace_root, score_selector=score
201
+ )
202
+
203
+ return _tool_result("session_open", _fn)
204
+
205
+ @server.tool() # type: ignore[untyped-decorator]
206
+ def session_status(session_id: str) -> dict[str, Any]:
207
+ """The session's current revision and (if `undo` was just called) its pending redo
208
+ target."""
209
+ return _tool_result(
210
+ "session_status",
211
+ lambda: session_service.session_status(session_id, workspace_root=workspace_root),
212
+ )
213
+
214
+ @server.tool() # type: ignore[untyped-decorator]
215
+ def session_checkout(
216
+ session_id: str, revision: int | None = None, checkpoint: str | None = None
217
+ ) -> dict[str, Any]:
218
+ """Jump directly to any existing revision, by number or by checkpoint name (exactly one
219
+ of `revision`/`checkpoint` is required)."""
220
+ return _tool_result(
221
+ "session_checkout",
222
+ lambda: session_service.session_checkout(
223
+ session_id,
224
+ revision=revision,
225
+ checkpoint=checkpoint,
226
+ workspace_root=workspace_root,
227
+ ),
228
+ )
229
+
230
+ @server.tool() # type: ignore[untyped-decorator]
231
+ def session_revisions(session_id: str) -> dict[str, Any]:
232
+ """Every revision's own metadata, sorted by revision number -- the full branch
233
+ structure."""
234
+ return _tool_result(
235
+ "session_revisions",
236
+ lambda: session_service.session_revisions(session_id, workspace_root=workspace_root),
237
+ )
238
+
239
+ return server
240
+
241
+
242
+ def run_stdio_server(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT) -> None:
243
+ """Build and run the MCP server over stdio -- the standard transport for a locally spawned
244
+ MCP server process."""
245
+ server = build_server(workspace_root=workspace_root)
246
+ server.run(transport="stdio")
@@ -0,0 +1,59 @@
1
+ """MEI adapter: optional MusicXML -> MEI conversion and MEI -> SVG render interoperability,
2
+ through the same optional Verovio integration (Section 8.2, 9.6; 20 Slice 9.6).
3
+
4
+ MEI (Music Encoding Initiative) is supported only for export and read-only rendering
5
+ interoperability -- never as an editable input format (CLAUDE.md: "MusicXML is canonical. Never
6
+ regenerate a score through MIDI, MEI, music21, Verovio, or MuseScore."). There is no
7
+ ``session open`` path for MEI and no MEI-sourced workspace: ``convert_to_mei`` returns a plain
8
+ string the caller may save wherever it likes, and ``render_mei_to_svg`` renders a standalone MEI
9
+ document directly, entirely outside the session/workspace system.
10
+
11
+ Reuses ``adapters.verovio``'s toolkit loader and error types -- MEI support is the same optional
12
+ Verovio dependency, just a different input/output format.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from mxl_agent.adapters.verovio import VerovioRenderError, _load_toolkit
20
+
21
+ __all__ = ["convert_to_mei", "render_mei_to_svg"]
22
+
23
+
24
+ def convert_to_mei(musicxml_bytes: bytes, *, options: dict[str, Any] | None = None) -> str:
25
+ """Convert ``musicxml_bytes`` (raw, uncompressed MusicXML) to an MEI XML string, through
26
+ Verovio. Export/interoperability only: the result is never written back as a session's
27
+ canonical document.
28
+
29
+ Raises ``VerovioUnavailableError``/``VerovioRenderError`` (same meanings as
30
+ ``adapters.verovio.render_score_page``).
31
+ """
32
+ toolkit = _load_toolkit(musicxml_bytes, options)
33
+ try:
34
+ return str(toolkit.getMEI())
35
+ except Exception as exc:
36
+ raise VerovioRenderError(f"verovio failed to convert to MEI: {exc}") from exc
37
+
38
+
39
+ def render_mei_to_svg(mei_bytes: bytes, *, page: int = 1) -> str:
40
+ """Render page ``page`` (1-based) of a standalone MEI document to an SVG string, through
41
+ Verovio. Read-only interoperability: ``mei_bytes`` is never opened as a session/workspace,
42
+ and MEI never becomes this project's canonical document.
43
+
44
+ Raises ``VerovioUnavailableError`` if the optional package isn't installed, or
45
+ ``VerovioRenderError`` if Verovio can't load ``mei_bytes`` or ``page`` is out of range.
46
+ """
47
+ toolkit = _load_toolkit(mei_bytes, input_from="mei")
48
+
49
+ page_count = toolkit.getPageCount()
50
+ if page < 1 or page > page_count:
51
+ raise VerovioRenderError(
52
+ f"page {page} is out of range (document has {page_count} page(s))",
53
+ page=page,
54
+ page_count=page_count,
55
+ )
56
+ try:
57
+ return str(toolkit.renderToSVG(page))
58
+ except Exception as exc:
59
+ raise VerovioRenderError(f"verovio failed to render page {page}: {exc}") from exc