Skip to content
Closed
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
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,38 @@ This is a PoC to tweak synths patches based on natural languages. This is also
an excuse to play a bit MCP, python libraries for LLM and have a bit of
fun.

I have only a couple of hours to build this PoC for AI tinkerers Japan.
I focus on interfacing with the Roland boutique JU-O6A: it is battery-powered,
which is nice for the demo, and is simple enough I can cover all of it easily.
Some cons of this synth: lack of SysEx, and inability to dump patch. But for the
demo it should do.
It enables using claude desktop to control a Roland JU-06A
connected through midi. Because the feature is exposed as an MCP server, it
should work with any client compatible with MCP, not just claude desktop.

This started as a demo for AI tinkerers Japan, and I had only a couple of
hours to build it. I focus on interfacing with the Roland boutique JU-O6A: it
is battery-powered, which is nice for the demo, and is simple enough I can
cover all of it easily. Some cons of this synth: lack of SysEx documentation,
and inability to read the synth state. But for the demo it should do.

## Setting it up

The MCP server is implemented in python. You need `uv` to be installed.

1. After cloning the repo, do `uv sync` to create the venv w/ all the
dependencies installed.
2. Then configure claude desktop.ai to use the MCP server. On mac, this would
be `~/Library/Application\ Support/Claude/claude_desktop_config.json`

``` json
{
"mcpServers": {
"text2synth": {
"command": "<path to the venv>/.venv/bin/python",
"args": ["-m", "text2synth.mcp_server"]
}
}
}
```

Note: the midi port is hard-coded. You will have to change it in
`src/text2text/mcp_server.py` file.

Copilot AI Oct 17, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected path from 'text2text' to 'text2synth' to match the actual project structure.

Suggested change
`src/text2text/mcp_server.py` file.
`src/text2synth/mcp_server.py` file.

Copilot uses AI. Check for mistakes.

## TODO

Expand Down
45 changes: 40 additions & 5 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import inspect
import io
import textwrap

from typing import Optional
from unittest import mock

from fastmcp import Client
from mcp.types import Tool, CallToolRequest, CallToolResult, TextContent
from pydantic import BaseModel, Field

from text2synth.mcp_server import create_function_from_model
from text2synth.mcp_server import INIT_106, create_function_from_model
from text2synth.state import JU06AState


class TestToolFactory:
Expand Down Expand Up @@ -37,8 +42,8 @@ def dummy(**kw): pass
def test_simple(self):
# Given
class SimpleModel(BaseModel):
a: int = Field(description="first arg")
b: int = Field(description="second arg")
a: int = Field(description="first arg")
b: int = Field(description="second arg")

def func_ref(a: Optional[int] = None, b: Optional[int] = None) -> None:
pass
Expand All @@ -65,8 +70,8 @@ def dummy(**kw): pass
def test_ranges(self):
# Given
class SimpleModel(BaseModel):
a: int = Field(ge=0, le=255, description="first arg")
b: int = Field(ge=0, le=127, description="second arg")
a: int = Field(ge=0, le=255, description="first arg")
b: int = Field(ge=0, le=127, description="second arg")

def func_ref(a: Optional[int] = None, b: Optional[int] = None) -> None:
pass
Expand All @@ -91,3 +96,33 @@ def dummy(**kw): pass
# Then
assert func.__doc__ == docstring_ref
assert func.__annotations__ == func_ref.__annotations__


class TestText2SynthMCPIntegration:
@mock.patch('text2synth.mcp_server.apply_state_to_synth')
async def test_reset(self, apply_state_to_synth):
# Given
from text2synth.mcp_server import server

# When
async with Client(server) as client:
result = await client.call_tool("reset", {})

# Then
assert apply_state_to_synth.called
assert not result.is_error

@mock.patch('text2synth.mcp_server.apply_state_to_synth')
async def test_update_synth_state(self, apply_state_to_synth):
# Given
from text2synth.mcp_server import server

r_state = JU06AState.from_file(io.StringIO(INIT_106))
r_state.cutoff = 42

# When
async with Client(server) as client:
result = await client.call_tool("update_synth_state", {"cutoff": r_state.cutoff})

# Then
apply_state_to_synth.assert_called_with(r_state)
Loading