Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ marimo/_lsp/
__marimo__/

# macOS
.DS_Store

AGENTS.md
# macOS
.DS_Store
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
Rôle et Contexte
Rôle : Vous êtes un développeur expert en Python, spécialisé dans l'intégration d'API, le protocole MCP (Model Context Protocol) et la manipulation de données musicales complexes.

Contexte : L'assistant LLM hallucine lorsqu'il lit des partitions MuseScore complexes (polyphonie) car le serveur MCP lui envoie actuellement un flux JSON brut, difficile à spatialiser temporellement pour une IA.

Objectif principal : Développer et intégrer une couche de traduction dans le serveur MCP Python. Les données JSON extraites de MuseScore doivent être parsées et converties en syntaxe LilyPond (.ly) avant d'être retournées au LLM en tant que chaîne de caractères.

Setup Commands / Instructions de Démarrage
Commandes pour préparer l'environnement de développement :

conda activate musescore-mcp (Utilisation de Python >= 3.12 requis)

pip install -r requirements.txt

(Optionnel en cas de mise à jour des dépendances) : pip freeze > requirements.txt

Dev Environment Tips / Astuces d'Environnement
Dépendance externe : Le serveur Python nécessite que MuseScore 4 soit ouvert en arrière-plan avec le plugin musescore-mcp-websocket.qml actif (écoute sur ws://localhost:8765).

Tests rapides : Pour tester la logique de parsing sans relancer Claude Desktop à chaque fois, créez un script Python temporaire qui simule l'appel WebSocket et affiche la sortie LilyPond générée dans le terminal.

Architecture cible de traduction :

Isoler les conteneurs (staff, voice).

Convertir les hauteurs numériques (MIDI pitch) en notes (c', d'').

Convertir les valeurs de durée temporelles en rythmes (4, 8).

Détecter et générer les silences (r).

Testing Instructions / Tests
Assurez-vous que le parsing ne lève pas d'exception si un champ JSON est manquant.

Commandes recommandées pour vérifier l'intégrité du code Python :

Lancement direct du serveur pour vérifier les erreurs de syntaxe : python server.py

(Si vous implémentez pytest) : pytest tests/

Code Style & Conventions
Typage statique : Utilisez les Type Hints de Python (typing) systématiquement pour les paramètres et les retours de fonctions, particulièrement lors de la manipulation du dictionnaire JSON.

Séparation des responsabilités : Ne mélangez pas la logique réseau (WebSocket) et la logique de parsing. Créez des fonctions dédiées (ex: json_to_lilypond(data: dict) -> str).

Fiabilité : Ajoutez une gestion d'erreurs stricte (blocs try/except) lors de l'extraction des nœuds JSON pour éviter de faire planter le serveur MCP si la partition MuseScore contient des éléments non standard.

Restrictions & Boundaries (Do's & Don'ts)
À FAIRE :

Concentrer les modifications exclusivement sur les outils de LECTURE (read_score, get_measure...).

S'assurer que le LLM reçoit une arborescence LilyPond valide (ex: << \new Voice { ... } \\ \new Voice { ... } >>).

À NE JAMAIS FAIRE :

Ne pas modifier le plugin côté MuseScore (musescore-mcp-websocket.qml). Le flux entrant MuseScore -> Python reste strictement en JSON.

Ne pas modifier les arguments d'entrée (signatures) des outils existants définis par @mcp.tool(). Le LLM doit continuer à appeler les outils exactement comme avant.

Ne pas toucher aux outils d'écriture/modification (add_note, etc.) durant cette phase d'implémentation.
8 changes: 4 additions & 4 deletions musescore-mcp-websocket.qml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ MuseScore {
case "syncStateToSelection": return syncStateToSelection();
case "ping": return "pong";
case "undo": return undo();
case "goToBeginningOfScore": return goToBeginningOfScore();
case "goToBeginningOfScore": return goToBeginningOfScore(command.params);
case "processSequence": return processSequence(command.params);

// Navigation
Expand Down Expand Up @@ -296,13 +296,13 @@ MuseScore {
});
}

function goToBeginningOfScore() {
function goToBeginningOfScore(params) {
var response = initCursorState();
return {
success: true,
message: response,
currentSelection: selectionState,
currentScore: getScoreSummary()
currentScore: params && (params.verbose !== "false" && params.verbose !== false) ? getScoreSummary() : null
};
}

Expand Down Expand Up @@ -415,7 +415,7 @@ MuseScore {
return {
success: true,
currentSelection: selectionState,
currentScore: params && params.verbose !== "false" ? getScoreSummary() : null
currentScore: params && (params.verbose !== "false" && params.verbose !== false) ? getScoreSummary() : null
};
}

Expand Down
22 changes: 3 additions & 19 deletions src/tools/connection.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Connection and utility tools for MuseScore MCP."""

from ..client import MuseScoreClient
from ..utils.response_formatter import run_and_format_response


def setup_connection_tools(mcp, client: MuseScoreClient):
Expand All @@ -15,26 +16,9 @@ async def connect_to_musescore():
@mcp.tool()
async def ping_musescore():
"""Ping the MuseScore WebSocket API to check connection."""
return await client.send_command("ping")
return await run_and_format_response(client, "ping")

@mcp.tool()
async def get_score():
"""Get information about the current score."""
res = await client.send_command("getScore")
if res.get("success") and "analysis" in res:
from ..utils.lilypond_converter import json_to_lilypond
analysis = res["analysis"]
lily_str = json_to_lilypond(analysis)

meta = []
if "numMeasures" in analysis:
meta.append(f"Total Mesures: {analysis['numMeasures']}")

num_staves = len(analysis.get("staves", []))
if num_staves > 0:
meta.append(f"Nombre de portées: {num_staves}")

meta_str = ", ".join(meta) if meta else "Aucune métadonnée"

return f"[Métadonnées] {meta_str}\n[Partition]\n{lily_str}"
return res
return await run_and_format_response(client, "getScore")
80 changes: 11 additions & 69 deletions src/tools/navigation.py
Original file line number Diff line number Diff line change
@@ -1,114 +1,56 @@
"""Cursor and navigation tools for MuseScore MCP."""

from ..client import MuseScoreClient
from ..utils.response_formatter import run_and_format_response


def setup_navigation_tools(mcp, client: MuseScoreClient):
"""Setup cursor and navigation tools."""

async def _run_and_convert(action: str, params=None):
res = await client.send_command(action, params)
if res.get("success") and "currentSelection" in res:
from ..utils.lilypond_converter import json_to_lilypond
sel = res["currentSelection"]
lily_str = json_to_lilypond(sel)

meta = []
score_info = res.get("currentScore", {})
if not isinstance(score_info, dict):
score_info = {}

if "startTick" in sel:
tick = sel["startTick"]
# Default math fallback
measure_num = (tick // 1920) + 1
beat_num = ((tick % 1920) // 480) + 1

if "measures" in score_info:
measures = score_info["measures"]
# Sort and find
measures = sorted(measures, key=lambda m: m.get("startTick", 0))
current_m = measures[0] if measures else {}
for i, m in enumerate(measures):
if m.get("startTick", 0) > tick:
break
current_m = m
measure_num = current_m.get("measure", measure_num)
m_start = current_m.get("startTick", 0)
beat_num = (max(0, tick - m_start) // 480) + 1

meta.append(f"Mesure: {measure_num}")
meta.append(f"Temps: {beat_num}")

if "startStaff" in sel:
start_s = sel["startStaff"]
end_s = sel.get("endStaff", start_s)
staff_name = f"{start_s}-{end_s}" if start_s != end_s else str(start_s)

if "staves" in score_info:
staves = score_info["staves"]
if 0 <= start_s < len(staves):
st_info = staves[start_s]
name = st_info.get("shortName") or st_info.get("name")
if name:
staff_name = name

meta.append(f"Portée: {staff_name}")

if "title" in score_info and score_info["title"]:
meta.append(f"Titre: {score_info['title']}")
if "numMeasures" in score_info:
meta.append(f"Total Mesures: {score_info['numMeasures']}")

meta_str = ", ".join(meta) if meta else "Aucune métadonnée"
return f"[Métadonnées] {meta_str}\n[Partition]\n{lily_str}"
return res
return res

@mcp.tool()
async def get_cursor_info():
"""Get information about the current cursor position."""
return await _run_and_convert("getCursorInfo")
return await run_and_format_response(client, "getCursorInfo", {"verbose": False})

@mcp.tool()
async def go_to_measure(measure: int):
"""Navigate to a specific measure."""
return await _run_and_convert("goToMeasure", {"measure": measure})
return await run_and_format_response(client, "goToMeasure", {"measure": measure})

@mcp.tool()
async def go_to_final_measure():
"""Navigate to the final measure of the score."""
return await _run_and_convert("goToFinalMeasure")
return await run_and_format_response(client, "goToFinalMeasure")

@mcp.tool()
async def go_to_beginning_of_score():
"""Navigate to the beginning of the score."""
return await _run_and_convert("goToBeginningOfScore")
return await run_and_format_response(client, "goToBeginningOfScore", {"verbose": False})

@mcp.tool()
async def next_element():
"""Move cursor to the next element."""
return await _run_and_convert("nextElement")
return await run_and_format_response(client, "nextElement")

@mcp.tool()
async def prev_element():
"""Move cursor to the previous element."""
return await _run_and_convert("prevElement")
return await run_and_format_response(client, "prevElement")

@mcp.tool()
async def next_staff():
"""Move cursor to the next staff."""
return await _run_and_convert("nextStaff")
return await run_and_format_response(client, "nextStaff")

@mcp.tool()
async def prev_staff():
"""Move cursor to the previous staff."""
return await _run_and_convert("prevStaff")
return await run_and_format_response(client, "prevStaff")

@mcp.tool()
async def select_current_measure():
"""Select the current measure."""
return await _run_and_convert("selectCurrentMeasure")
return await run_and_format_response(client, "selectCurrentMeasure")

@mcp.tool()
async def select_custom_range(start_tick: int, end_tick: int, start_staff: int, end_staff: int):
Expand All @@ -122,4 +64,4 @@ async def select_custom_range(start_tick: int, end_tick: int, start_staff: int,
"startStaff": start_staff,
"endStaff": end_staff
}
return await _run_and_convert("selectCustomRange", params)
return await run_and_format_response(client, "selectCustomRange", params)
17 changes: 9 additions & 8 deletions src/tools/notes_measures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import List, Optional
from ..client import MuseScoreClient
from ..utils.response_formatter import run_and_format_response


def setup_notes_measures_tools(mcp, client: MuseScoreClient):
Expand All @@ -16,7 +17,7 @@ async def add_note(pitch: int = 64, duration: dict = {"numerator": 1, "denominat
duration: Duration as {"numerator": int, "denominator": int} (e.g., {"numerator": 1, "denominator": 4} for quarter note)
advance_cursor_after_action: Whether to move cursor to next position after adding note
"""
return await client.send_command("addNote", {
return await run_and_format_response(client, "addNote", {
"pitch": pitch,
"duration": duration,
"advanceCursorAfterAction": advance_cursor_after_action
Expand All @@ -30,7 +31,7 @@ async def add_rest(duration: dict = {"numerator": 1, "denominator": 4}, advance_
duration: Duration as {"numerator": int, "denominator": int} (e.g., {"numerator": 1, "denominator": 4} for quarter rest)
advance_cursor_after_action: Whether to move cursor to next position after adding rest
"""
return await client.send_command("addRest", {
return await run_and_format_response(client, "addRest", {
"duration": duration,
"advanceCursorAfterAction": advance_cursor_after_action
})
Expand All @@ -44,7 +45,7 @@ async def add_tuplet(duration: dict = {"numerator": 1, "denominator": 4}, ratio:
ratio: Tuplet ratio as {"numerator": int, "denominator": int} (e.g., {"numerator": 3, "denominator": 2} for triplet)
advance_cursor_after_action: Whether to move cursor to next position after adding tuplet
"""
return await client.send_command("addTuplet", {
return await run_and_format_response(client, "addTuplet", {
"duration": duration,
"ratio": ratio,
"advanceCursorAfterAction": advance_cursor_after_action
Expand All @@ -58,30 +59,30 @@ async def add_lyrics(lyrics: List[str], verse: int = 0):
lyrics: List of lyric syllables to add (e.g., ["Hel", "lo", "world"])
verse: Verse number (0-based, default is 0 for first verse)
"""
return await client.send_command("addLyrics", {
return await run_and_format_response(client, "addLyrics", {
"lyrics": lyrics,
"verse": verse
})

@mcp.tool()
async def insert_measure():
"""Insert a measure at the current position."""
return await client.send_command("insertMeasure")
return await run_and_format_response(client, "insertMeasure")

@mcp.tool()
async def append_measure(count: int = 1):
"""Append measures to the end of the score."""
return await client.send_command("appendMeasure", {"count": count})
return await run_and_format_response(client, "appendMeasure", {"count": count})

@mcp.tool()
async def delete_selection(measure: Optional[int] = None):
"""Delete the current selection or specified measure."""
params = {}
if measure is not None:
params["measure"] = measure
return await client.send_command("deleteSelection", params)
return await run_and_format_response(client, "deleteSelection", params)

@mcp.tool()
async def undo():
"""Undo the last action."""
return await client.send_command("undo")
return await run_and_format_response(client, "undo")
3 changes: 2 additions & 1 deletion src/tools/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from ..client import MuseScoreClient
from ..types import ActionSequence
from ..utils.response_formatter import run_and_format_response


def setup_sequence_tools(mcp, client: MuseScoreClient):
Expand All @@ -10,4 +11,4 @@ def setup_sequence_tools(mcp, client: MuseScoreClient):
@mcp.tool()
async def processSequence(sequence: ActionSequence):
"""Process a sequence of commands."""
return await client.send_command("processSequence", {"sequence": sequence})
return await run_and_format_response(client, "processSequence", {"sequence": sequence})
Loading