mcp-musescore-extended
by ivanlyf0712
README.md
# mcp-musescore-extended
Control a live MuseScore Studio score from an AI assistant over
[MCP](https://modelcontextprotocol.io). A QML plugin inside MuseScore opens a
WebSocket server; a Python MCP server connects to it and exposes the score as
tools the model can call.
An **extended fork** of [ghchen99/mcp-musescore](https://github.com/ghchen99/mcp-musescore):
it adds the notation layer upstream does not have, plus a passage format for
reading and writing music compactly. Upstream is the base, not a competitor —
this fork was 0 commits behind it when these changes were made.
- **[`CHANGES.md`](CHANGES.md)** — exactly what differs from upstream, with
measurements and the failure each fix addresses.
- **[`SPEC.md`](SPEC.md)** — the design behind the passage format.
> **Where this is going:** [`SPEC-2.md`](SPEC-2.md) — the round-2 spec covering
> MuseScore version support, universal write verification, packaging and the gaps
> against the closest competitor. Background survey of 89 music MCP projects in
> [`NOTES-mcp-landscape.md`](NOTES-mcp-landscape.md).
---
## ⚠️ Read this first: MuseScore version
**This bridge requires MuseScore Studio 4.6.x or earlier. It does not work on
4.7.x.**
MuseScore 4.7 removed the WebSocket plugin API. The API object still appears in
the plugin context, but it resolves to `undefined`. MuseScore's own log says:
```
ERROR | ApiRegister::createApi | not registered creator for api: MuseApi.WebsocketServer
WARN | ExtApiV1::api | Not allowed api: MuseApi.WebsocketServer
INFO | Api | "typeof api.websocketserver = undefined"
```
Verified on 4.7.4 and 4.7.5. Verified **working** on 4.6.5:
```
INFO | Api | "typeof api.websocketserver = object"
INFO | Api | "LISTEN-OK port 8765"
```
If you are on 4.7.x, install 4.6.5 alongside it and run the bridge there. Both
versions can coexist, but note they share
`~/Library/Application Support/MuseScore/MuseScore4/` and
`~/Library/Preferences/org.musescore.MuseScore4.plist` — back those up before
first use.
---
## What this fork adds
Upstream covers notes, rests, tuplets, lyrics, measures, navigation and
selection. This fork adds the notation layer and fixes a class of silent
misdirected writes. **Full detail, with measurements and the failure behind each
fix, is in [`CHANGES.md`](CHANGES.md).**
**New notation tools**
| Tool | Element |
|---|---|
| `add_slur` | Slur (spanner) |
| `add_dynamic` | Dynamic marking, with correct SMuFL markup |
| `add_articulation` | Staccato, tenuto, accent, marcato, portato, and combinations |
| `add_hairpin` | Crescendo / diminuendo (spanner) |
| `add_key_signature` | Key signature, per-staff or score-wide |
| `add_clef` | Clef, including mid-piece changes on a barline |
| `add_tie` | Tie — via MuseScore's own `tie` action; see [`CHANGES.md` §3](CHANGES.md) |
| `set_tempo` | Tempo text |
| `get_notations` | Read back dynamics, articulations and spanners |
| `delete_notation` | Remove one notation kind at a tick |
**New passage layer** — `read_passage`, `write_passage`, `probe_action`. Reading
a 117-measure score as compact text costs 9.4 KB where `get_score` costs 134 KB
(~33k tokens). `write_passage` validates everything before writing and then reads
the bar back and compares.
**New transposition** — `transpose_chromatic`, `transpose_diatonic`,
`transpose_to_key`, plus `save` (in-place, via MuseScore's own action).
**New rendering** — `render_score` exports PDF, PNG, SVG, MIDI, MP3, WAV,
MusicXML or `.mscz`/`.mscx` through the MuseScore **command line**, and
`score_metadata` reads a file's title, duration, measure count and key. Neither
needs a running MuseScore, the plugin, or a bridge connection — which also makes
this the one part of the server with no MuseScore **version** constraint.
**Also added:** `add_tuplet_run` (atomic tuplet runs on an explicit staff) and
`add_staff_text`.
**Cursor fixes.** The upstream cursor used `INPUT_STATE_SYNC_WITH_SCORE`, so an
explicit `staffIdx` did not reliably stick and writes could land on the wrong
staff — intermittently, and drifting mid-run. Explicit-staff calls now use
`INPUT_STATE_INDEPENDENT`, and two fallback paths that hardcoded staff 0 were
fixed.
**Dynamic fix.** A Dynamic created through the API starts with empty custom
text; MuseScore saves that as `<text/>` and draws **nothing** while still
playing back correctly. `add_dynamic` now sets the SMuFL markup explicitly
(`p` → `<sym>dynamicPiano</sym>`), and repairs a same-type dynamic in place
rather than stacking duplicates.
---
## Architecture
```
AI client (Claude Desktop, DSH, ...)
│ MCP over stdio
▼
server.py ─ FastMCP, tool schemas live here
│ ws://localhost:8765
▼
musescore-mcp-websocket.qml ─ runs inside MuseScore
│
▼
MuseScore Studio 4.6.x
```
---
## Setup
### 1. Install the plugin
Copy `musescore-mcp-websocket.qml` into MuseScore's plugin folder:
| OS | Path |
|---|---|
| macOS | `~/Documents/MuseScore4/Plugins/` |
| Linux | `~/Documents/MuseScore4/Plugins/` |
| Windows | `%USERPROFILE%\Documents\MuseScore4\Plugins\` |
### 2. Enable the plugin
Plugins are **disabled by default**. Enable it once:
1. Open MuseScore → **Home → Plugins**
2. Find *MuseScore API Server* → **Enable**
If you run it while still disabled, MuseScore shows a modal:
> The plugin "musescore-mcp-websocket" is currently disabled. Do you want to
> enable it now?
It is easy to miss. The enabled list lives in
`<appdata>/extensions/config.json`.
### 3. Python environment
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt # mcp[cli]<2, websockets
```
`mcp` must be **1.x**. Version 2.x moved to `typing_extensions.TypedDict`, which
breaks the typed action definitions on Python < 3.12.
### 4. Point your MCP client at `server.py`
```json
{
"mcpServers": {
"musescore": {
"command": "/path/to/.venv/bin/python",
"args": ["/path/to/server.py"]
}
}
}
```
### 5. Run
1. Start MuseScore **4.6.x** with a score open
2. **Plugins → MuseScore API Server**
3. Start your MCP client
The connection is made lazily and reconnects on failure, so the Python server
may be started before or after the plugin.
---
## Tools
### Connection
`ping_musescore` · `connect_to_musescore` · `get_score`
`get_score` dumps the entire score and is expensive on a large file. Use it to
learn the score's shape, then prefer `select_custom_range` over a narrow tick
range for anything else.
### Navigation and selection
`get_cursor_info` · `go_to_measure` · `go_to_beginning_of_score` ·
`go_to_final_measure` · `next_element` · `prev_element` · `next_staff` ·
`prev_staff` · `select_current_measure` · `select_custom_range`
### Notes and rests
`add_note` · `add_rest` · `add_tuplet` · `add_tuplet_run` · `add_lyrics` ·
`add_staff_text`
`add_note` **replaces** the chord at the cursor rather than stacking a second
one, so re-voicing a passage in place is safe and needs no delete.
### Notation
`add_slur` · `add_dynamic` · `add_articulation` · `add_hairpin` ·
`add_key_signature` · `add_clef` · `set_tempo` · `get_notations` ·
`delete_notation`
### Structure
`insert_measure` · `append_measure` · `delete_selection` · `undo`
### Staves and instruments
`add_instrument` · `set_staff_mute` · `set_instrument_sound`
### Time
`set_time_signature`
### Batch
`processSequence` — run a list of actions inside a single undo group.
---
## Concepts
### Ticks and durations
`Division` is 480 per quarter note, so a whole note is **1920 ticks** and a
measure of 4/4 must total exactly 1920. Durations are written as fractions:
```json
{"numerator": 1, "denominator": 4} // quarter note
{"numerator": 3, "denominator": 8} // dotted quarter
```
### Spanner ticks are onset-to-onset
`add_slur` and `add_hairpin` take `ticks` measured **to the onset of the last
note — not past its end**. An eighth pair 240 ticks apart needs `ticks = 240`,
not 480. Passing the full span makes the spanner swallow the following note.
```
ticks = lastNoteOnset − firstNoteOnset
```
### Staff indices are 0-based
On a cello + harp score: `0` = cello, `1` = harp upper, `2` = harp lower.
Confirm before writing — the live selection decides the staff.
### `select_custom_range` reads and writes want different arguments
`endStaff` is **exclusive**:
| Intent | Arguments |
|---|---|
| Read staff 1 only | `start_staff=1, end_staff=1` |
| Write to staff 1 | `start_staff=1, end_staff=2` |
With `start_staff == end_staff` for a write, the selection is empty and the
cursor falls back to staff 0. A read also destroys the write selection, so
never read and then write without re-selecting.
### Articulations attach to chords, dynamics to segments
`add_articulation` must land on a **note**. Aimed at a rest it returns an error
rather than reporting success.
---
## Known limitations
**Ties work, but not where you would look for them.** `add_tie` runs MuseScore's
own `tie` action over a range; the application builds the tie, so nothing is
hand-built. Do **not** construct a `Tie` through `newElement(Element.TIE)` — the
plugin API has no `TIE` case and a hand-built Tie with no start or end note
**crashes MuseScore outright with no crash report**. Two properties matter: the
action is per-**range** (it cannot tie one pitch of a chord and leave another,
which is why `write_passage` refuses the grammar's per-pitch `~`) and it
**toggles** (run it on an already-tied note and the tie is removed). Read the
passage back and tie only what is untied; see [`CHANGES.md` §3](CHANGES.md).
**Saving and export.** `save` dispatches MuseScore's own `file-save` and is
measured working, but it saves **in place** — a score that has never been saved
has nowhere to go and MuseScore opens a Save dialog, which stalls the bridge
until dismissed. The plugin API gives no reliable way to detect that case
(`Score.path` reads null even for a score opened from disk), so know whether the
score has been saved once already. `save_score`, which called `writeScore()` and
could never work, has been removed.
**Notation is invisible to the ordinary reads.** `get_score`,
`get_cursor_info` and `select_custom_range` only ever report `Chord` and
`Rest`. Use `get_notations` for dynamics, articulations and spanners.
**A range `delete_selection` fails on tuplet content** — it reports success and
changes nothing. Select the measure first, then delete.
**`addTuplet` mid-bar can snap backwards** and swallow the preceding note,
because its cursor rewinds to the last existing segment. Prefer a plain rhythm;
if a mid-bar tuplet is essential, write a rest up to the target tick first and
verify immediately.
**Plugin `console.log()` goes nowhere.** It is not written to MuseScore's log
or to stdout. Use `api.log.info(...)` — output lands in
`<appdata>/logs/MuseScore_*.log` under the `Api` context.
**Other gaps:** barlines/repeats, rehearsal marks, ornaments, tremolo,
arpeggio, glissando, 8va and pedal lines, grace notes, and explicit accidentals
are not exposed. `newElement(Element.X)` + `cursor.add()` can create them — the
plugin just does not wrap them yet.
---
## Development
### The running plugin does not reload
Editing the `.qml` does nothing to a running instance. Re-running the plugin
does not help either — if port 8765 is already held, MuseScore logs
`QTcpServer::listen() called when already listening` and the **old** instance
keeps serving.
To load plugin changes: **quit MuseScore completely, reopen it, run the
plugin.**
Python changes are different: `src/tools/*.py` and `src/types/*.py` define the
tool schema, so the MCP server process must restart. Killing it is enough if
your client reconnects.
### Keep both plugin copies identical
```
~/Documents/MuseScore4/Plugins/musescore-mcp-websocket.qml # what MuseScore loads
./musescore-mcp-websocket.qml # what git tracks
```
```bash
cp musescore-mcp-websocket.qml ~/Documents/MuseScore4/Plugins/
shasum -a 256 musescore-mcp-websocket.qml ~/Documents/MuseScore4/Plugins/musescore-mcp-websocket.qml
```
### Writing to the plugin API
* Methods must start with a **lowercase** letter — `function logLine()`, not
`function LogLine()`. QML rejects the file otherwise.
* Set spanner ticks **before** `cursor.add()`; `spannerTick` / `spannerTicks`
are the only handles, since `startElement` / `endElement` are read-only.
* For an explicit staff, build the cursor yourself:
`cursor.inputStateMode = Cursor.INPUT_STATE_INDEPENDENT` and set `staffIdx`.
### Debugging
```bash
ls -t ~/Library/Application\ Support/MuseScore/MuseScore4/logs/ | head -1
```
Load errors appear as `ExtPluginRunner::run | Failed to load QML file`. A
`success: true` from a tool is **not** evidence the write landed — read it back.
### Fallback: MusicXML round-trip
When the bridge cannot express something, write MusicXML and convert it with
MuseScore's own reader:
```bash
"/Applications/MuseScore 4.6.5.app/Contents/MacOS/mscore" -o out.mscz in.musicxml
```
Importer quirks worth knowing: `<type>` wins over `<duration>` when they
disagree; `<dot>` on a rest is dropped; a leading gap split across two rests
loses the second one. Write leading gaps as a single un-dotted rest, no longer
than two beats, then verify the tick sum per measure.
---
## File structure
```
├── server.py # MCP entry point
├── musescore-mcp-websocket.qml # MuseScore plugin (the bridge)
├── requirements.txt
└── src/
├── client/
│ └── websocket_client.py # WebSocket transport, reconnect logic
├── tools/ # MCP tool definitions
│ ├── connection.py
│ ├── navigation.py
│ ├── notes_measures.py # notes, rests, tuplets, and all notation
│ ├── sequences.py # processSequence
│ ├── staff_instruments.py
│ └── time_tempo.py
└── types/
└── action_types.py # typed action params
```
---
## Reference
**MIDI pitch:** middle C = 60; C major scale = 60, 62, 64, 65, 67, 69, 71, 72.
**Clefs:** `treble bass tenor alto soprano mezzo baritone treble8vb bass8vb`
**Keys:** `C G D A E B F# C# F Bb Eb Ab Db Gb Cb`
**Modes:** `major minor dorian phrygian lydian mixolydian aeolian ionian locrian`
**Hairpins:** `cresc dim crescline dimline`
**Dynamics:** `pppppp` … `ffffff`, `p mp mf f`, `fp pf sf sfz sff sffz sfp`,
`rf rfz fz m r`
**Articulations:** `staccato staccatissimo tenuto accent marcato tenutoaccent
accentstaccato marcatostaccato marcatotenuto portato staccatissimostroke
staccatissimowedges`
---
## Credits
Built on [ghchen99/mcp-musescore](https://github.com/ghchen99/mcp-musescore) by
George Chen and contributors, which builds the bridge and the note-level tooling
this fork extends. Licensed MIT under the same terms as upstream; `LICENSE` is
upstream's, unmodified, and the full commit history is preserved so every
upstream contribution stays attributed.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessResponsive