Skip to main content
Glama
ivanlyf0712

mcp-musescore-extended

by ivanlyf0712

mcp-musescore-extended

Control a live MuseScore Studio score from an AI assistant over MCP. 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: 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 — exactly what differs from upstream, with measurements and the failure each fix addresses.

  • SPEC.md — the design behind the passage format.

Where this is going: 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.


⚠️ 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.


Related MCP server: MuseScore MCP Server

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.

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

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

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

{
  "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:

{"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.

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
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

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:

"/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 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.

Related MCP Connectors

Related MCP Servers