Skip to main content
Glama
ivanlyf0712

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.