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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,9 @@ appcast.*.xml
*.tar.gz
.vscode/
Microsoft.AI.DirectML

# Workspace build artifacts
.dotnet/
openutau-build/
openutau-runtime/
OpenUtau-linux-x64.zip
656 changes: 656 additions & 0 deletions OpenUtau.Core/AgentBridge/BridgeCore.cs

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions OpenUtau.Core/AgentBridge/BridgeProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;

namespace OpenUtau.Core.AgentBridge {
/// <summary>Compact v2 envelope shared by the local MCP coordinator and OpenUtau.</summary>
public static class BridgeProtocol {
public const int Version = 2;
public const string BridgeVersion = "0.2.0";
public const string RequestFileName = "openutau-agent-bridge.request.json";
public const string ProcessingFileName = "openutau-agent-bridge.processing.json";
public const string ResponseFileName = "openutau-agent-bridge.response.json";
public const string StatusFileName = "openutau-agent-bridge.status.json";
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal) {
"ping", "get_project_info", "get_state_snapshot", "get_state_events", "get_bridge_diagnostics", "set_track_config", "set_track_singer", "create_part", "add_notes_simple",
"edit_note_simple", "delete_notes_simple", "playback", "get_editor_state", "save_file", "load_file",
"navigate_editor", "open_piano_roll",
};

public static bool IsSupportedAction(string? action) => action != null && Actions.Contains(action);
}
}
414 changes: 414 additions & 0 deletions OpenUtau.Core/AgentBridge/McpService.cs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion OpenUtau.Core/Util/PathManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public PathManager() {
DataPath = exePath;
} else {
string dataHome = Environment.GetFolderPath(Environment.SpecialFolder.Personal);
DataPath = Path.Combine(dataHome, "OpenUtau");
var dataDirectoryName = File.Exists(Path.Combine(exePath, "installed-mcp.txt")) ? "OpenUtau MCP" : "OpenUtau";
DataPath = Path.Combine(dataHome, dataDirectoryName);
}
CachePath = Path.Combine(DataPath, "Cache");
HomePathIsAscii = true;
Expand Down
5 changes: 5 additions & 0 deletions OpenUtau.Core/Util/Preferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ public class SerializablePreferences {
public Dictionary<string, string> SingerPhonemizers = new Dictionary<string, string>();
public List<string> RecentPhonemizers = new List<string>();
public bool PreferPortAudio = false;
public bool McpEnabled = false;
public AgentBridge.McpStartupMode McpStartupMode = AgentBridge.McpStartupMode.Manual;
public string McpBindAddress = "127.0.0.1";
public int McpPort = 43102;
public string McpToken = string.Empty;
public bool UseSystemDefaultAudioDevice = true;
public double PlayPosMarkerMargin = 0.9;
public int LockStartTime = 0;
Expand Down
166 changes: 166 additions & 0 deletions OpenUtau.Test/AgentBridge/BridgeProtocolTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using OpenUtau.Core.AgentBridge;
using OpenUtau.Core.Util;
using OpenUtau.Core.Ustx;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Xunit;

namespace OpenUtau.Test.AgentBridge {
public class BridgeProtocolTest {
[Fact]
public void V2ProtocolExposesOnlyBuiltInActions() {
Assert.Equal(2, BridgeProtocol.Version);
Assert.True(BridgeProtocol.IsSupportedAction("add_notes_simple"));
Assert.True(BridgeProtocol.IsSupportedAction("get_state_snapshot"));
Assert.True(BridgeProtocol.IsSupportedAction("get_state_events"));
Assert.True(BridgeProtocol.IsSupportedAction("get_bridge_diagnostics"));
Assert.False(BridgeProtocol.IsSupportedAction("apply_transaction"));
Assert.True(BridgeProtocol.IsSupportedAction("save_file"));
Assert.True(BridgeProtocol.IsSupportedAction("load_file"));
Assert.True(BridgeProtocol.IsSupportedAction("navigate_editor"));
Assert.True(BridgeProtocol.IsSupportedAction("open_piano_roll"));
Assert.False(BridgeProtocol.IsSupportedAction("ui_click"));
}

[Fact]
public void StateSnapshotIncludesEditableNoteDetails() {
var note = UNote.Create();
note.position = 120;
note.duration = 480;
note.tone = 60;
note.lyric = "a";
note.tuning = 12;
note.pitch.AddPoint(new PitchPoint(25, 15, PitchPointShape.l));
note.vibrato.length = 65;
note.phonemeExpressions.Add(new UExpression("vel") { index = 0, value = 80 });
note.phonemeOverrides.Add(new UPhonemeOverride { index = 0, phoneme = "ka", offset = 10 });

using var document = JsonDocument.Parse(JsonSerializer.Serialize(BridgeCore.SnapshotNote(note, 3)));
var snapshot = document.RootElement;

Assert.Equal(3, snapshot.GetProperty("index").GetInt32());
Assert.Equal(25, snapshot.GetProperty("pitch").GetProperty("points")[0].GetProperty("x").GetSingle());
Assert.Equal("l", snapshot.GetProperty("pitch").GetProperty("points")[0].GetProperty("shape").GetString());
Assert.Equal(65, snapshot.GetProperty("vibrato").GetProperty("length").GetSingle());
Assert.Equal("vel", snapshot.GetProperty("phonemeExpressions")[0].GetProperty("abbr").GetString());
Assert.Equal("ka", snapshot.GetProperty("phonemeOverrides")[0].GetProperty("phoneme").GetString());
Assert.Equal(10, snapshot.GetProperty("phonemeOverrides")[0].GetProperty("offset").GetInt32());
}

[Fact]
public async Task HttpMcpRequiresTokenAndIgnoresLegacySessionHeaders() {
var port = GetAvailablePort();
var savedToken = Preferences.Default.McpToken;
Preferences.Default.McpToken = string.Empty;
Assert.True(McpServiceOptions.TryCreate("127.0.0.1", port, out var options, out _));
Assert.True(McpService.Start(options, out _));
try {
using var client = new HttpClient();
var endpoint = $"http://127.0.0.1:{port}/mcp";
var anonymous = await client.GetAsync(endpoint);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);

Assert.True(McpService.TryGetBearerToken(out var token));
Assert.NotEmpty(token);
McpService.Stop();
Assert.True(McpService.Start(options, out _));
Assert.True(McpService.TryGetBearerToken(out var restartedToken));
Assert.Equal(token, restartedToken);
McpService.RefreshBearerToken();
Assert.True(McpService.TryGetBearerToken(out var refreshedToken));
Assert.NotEqual(token, refreshedToken);
Assert.True(McpService.TryGetConnectionConfiguration(out var configuration));
using var configDocument = JsonDocument.Parse(configuration);
var authorization = configDocument.RootElement
.GetProperty("mcpServers").GetProperty("openutau").GetProperty("headers")
.GetProperty("Authorization").GetString();
Assert.Equal($"Bearer {refreshedToken}", authorization);

using var expiredRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) {
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\"}", Encoding.UTF8, "application/json"),
};
expiredRequest.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
expiredRequest.Headers.TryAddWithoutValidation("Accept", "application/json");
var expiredResponse = await client.SendAsync(expiredRequest);
Assert.Equal(HttpStatusCode.Unauthorized, expiredResponse.StatusCode);

using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) {
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}", Encoding.UTF8, "application/json"),
};
request.Headers.TryAddWithoutValidation("Authorization", authorization);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("mcp-session-id"));

using var toolsRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) {
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}", Encoding.UTF8, "application/json"),
};
toolsRequest.Headers.TryAddWithoutValidation("Authorization", authorization);
toolsRequest.Headers.TryAddWithoutValidation("Accept", "application/json");
toolsRequest.Headers.TryAddWithoutValidation("mcp-session-id", "legacy-client-session");
var toolsResponse = await client.SendAsync(toolsRequest);
Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode);
Assert.Equal("no-store", toolsResponse.Headers.CacheControl?.ToString());
Assert.True(toolsResponse.Headers.TryGetValues("X-Content-Type-Options", out var contentTypeOptions));
Assert.Contains("nosniff", contentTypeOptions);
using var toolsDocument = JsonDocument.Parse(await toolsResponse.Content.ReadAsStringAsync());
var tools = toolsDocument.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray().ToArray();
var readTool = tools.Single(tool => tool.GetProperty("name").GetString() == "openutau_read");
var readSchema = readTool.GetProperty("inputSchema");
Assert.Equal("object", readSchema.GetProperty("properties").GetProperty("payload").GetProperty("type").GetString());
Assert.Equal("action", readSchema.GetProperty("required")[0].GetString());
Assert.False(readSchema.GetProperty("additionalProperties").GetBoolean());

using var planRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) {
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"openutau_plan\",\"arguments\":{\"action\":\"create_part\"}}}", Encoding.UTF8, "application/json"),
};
planRequest.Headers.TryAddWithoutValidation("Authorization", authorization);
planRequest.Headers.TryAddWithoutValidation("Accept", "application/json");
var planResponse = await client.SendAsync(planRequest);
Assert.Equal(HttpStatusCode.OK, planResponse.StatusCode);
using var planDocument = JsonDocument.Parse(await planResponse.Content.ReadAsStringAsync());
var planText = planDocument.RootElement.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString();
using var planPayload = JsonDocument.Parse(planText!);
Assert.Equal("pending", planPayload.RootElement.GetProperty("status").GetString());
Assert.False(string.IsNullOrWhiteSpace(planPayload.RootElement.GetProperty("planId").GetString()));

using var callRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) {
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"unknown\"}}", Encoding.UTF8, "application/json"),
};
callRequest.Headers.TryAddWithoutValidation("Authorization", authorization);
callRequest.Headers.TryAddWithoutValidation("Accept", "application/json");
callRequest.Headers.TryAddWithoutValidation("mcp-session-id", "legacy-client-session");
var callResponse = await client.SendAsync(callRequest);
Assert.Equal(HttpStatusCode.BadRequest, callResponse.StatusCode);
using var callDocument = JsonDocument.Parse(await callResponse.Content.ReadAsStringAsync());
Assert.Equal(-32602, callDocument.RootElement.GetProperty("error").GetProperty("code").GetInt32());
} finally {
McpService.Stop();
Preferences.Default.McpToken = savedToken;
Preferences.Save();
}
}

[Fact]
public void McpServiceOptionsAcceptOnlyLoopbackAddressesAndValidPorts() {
Assert.True(McpServiceOptions.TryCreate("::1", 43102, out _, out _));
Assert.False(McpServiceOptions.TryCreate("0.0.0.0", 43102, out _, out _));
Assert.False(McpServiceOptions.TryCreate("127.0.0.1", 0, out _, out _));
Assert.False(McpServiceOptions.TryCreate("127.0.0.1", 65536, out _, out _));
}

private static int GetAvailablePort() {
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
}
31 changes: 31 additions & 0 deletions OpenUtau/Strings/Strings.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,37 @@ Warning: this option removes custom presets.</system:String>
<system:String x:Key="prefs.diffsinger">DiffSinger</system:String>
<system:String x:Key="prefs.editing">Editing</system:String>
<system:String x:Key="prefs.general">General</system:String>
<system:String x:Key="prefs.mcp">MCP</system:String>
<system:String x:Key="prefs.mcp.service">MCP local service</system:String>
<system:String x:Key="prefs.mcp.description">Provides OpenUtau state and controls to local automation tools. The service accepts loopback connections only.</system:String>
<system:String x:Key="prefs.mcp.enabled">Enable MCP service</system:String>
<system:String x:Key="prefs.mcp.startupmode">Startup mode</system:String>
<system:String x:Key="prefs.mcp.startupmode.manual">Start manually</system:String>
<system:String x:Key="prefs.mcp.startupmode.automatic">Start when OpenUtau starts</system:String>
<system:String x:Key="prefs.mcp.bindaddress">Bind address</system:String>
<system:String x:Key="prefs.mcp.port">Port</system:String>
<system:String x:Key="prefs.mcp.hint">Use the MCP menu next to Help to start the service, check its status, or copy the connection configuration.</system:String>
<system:String x:Key="mcp.caption">MCP</system:String>
<system:String x:Key="mcp.menu.start">Start MCP service</system:String>
<system:String x:Key="mcp.menu.stop">Stop MCP service</system:String>
<system:String x:Key="mcp.menu.status">MCP service status</system:String>
<system:String x:Key="mcp.menu.copytoken">Copy MCP token</system:String>
<system:String x:Key="mcp.menu.refreshtoken">Refresh MCP token</system:String>
<system:String x:Key="mcp.menu.copyconfiguration">Copy MCP connection configuration</system:String>
<system:String x:Key="mcp.error.invalidconfig">MCP configuration is invalid.</system:String>
<system:String x:Key="mcp.error.startfailed">MCP service failed to start.</system:String>
<system:String x:Key="mcp.error.notrunning.copytoken">Start the MCP service before copying its token.</system:String>
<system:String x:Key="mcp.error.notrunning.copyconfig">Start the MCP service before copying its connection configuration.</system:String>
<system:String x:Key="mcp.started">MCP service started.\nEndpoint: {0}</system:String>
<system:String x:Key="mcp.stopped">MCP service stopped.</system:String>
<system:String x:Key="mcp.status.running">running</system:String>
<system:String x:Key="mcp.status.stopped">stopped</system:String>
<system:String x:Key="mcp.status.error">\nError: {0}</system:String>
<system:String x:Key="mcp.status">MCP service is {0}.\nEndpoint: {1}{2}</system:String>
<system:String x:Key="mcp.token.copied">The current MCP token has been copied. Clear your clipboard after use.</system:String>
<system:String x:Key="mcp.token.refresh.confirm">Refreshing the token requires connected MCP clients to update their configuration. Continue?</system:String>
<system:String x:Key="mcp.token.refreshed">MCP token refreshed. Update connected clients with the new token.</system:String>
<system:String x:Key="mcp.configuration.copied">The MCP connection configuration has been copied. Clear your clipboard after use.</system:String>
<system:String x:Key="prefs.note.restart">Note: please restart OpenUtau after changing this item.</system:String>
<system:String x:Key="prefs.otoeditor">Oto Editor</system:String>
<system:String x:Key="prefs.otoeditor.select">Default Oto Editor</system:String>
Expand Down
35 changes: 35 additions & 0 deletions OpenUtau/ViewModels/PreferencesViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using OpenUtau.Audio;
using OpenUtau.Classic;
using OpenUtau.Core;
using OpenUtau.Core.AgentBridge;
using OpenUtau.Core.Util;
using ReactiveUI;
using ReactiveUI.Fody.Helpers;
Expand Down Expand Up @@ -129,6 +130,12 @@ public int SafeMaxThreadCount {
[Reactive] public bool RememberVsqx { get; set; }
public string WinePath => Preferences.Default.WinePath;

// MCP
[Reactive] public bool McpEnabled { get; set; }
[Reactive] public McpStartupMode McpStartupMode { get; set; }
[Reactive] public string McpBindAddress { get; set; }
[Reactive] public int McpPort { get; set; }

public PreferencesViewModel() {
var audioOutput = PlaybackManager.Inst.AudioOutput;
if (audioOutput != null) {
Expand Down Expand Up @@ -192,6 +199,10 @@ public PreferencesViewModel() {
RememberUst = Preferences.Default.RememberUst;
RememberVsqx = Preferences.Default.RememberVsqx;
ClearCacheOnQuit = Preferences.Default.ClearCacheOnQuit;
McpEnabled = Preferences.Default.McpEnabled;
McpStartupMode = Preferences.Default.McpStartupMode;
McpBindAddress = Preferences.Default.McpBindAddress;
McpPort = Preferences.Default.McpPort;

MessageBus.Current.Listen<ThemeEditorStateChangedEvent>()
.Subscribe(_ => this.RaisePropertyChanged(nameof(IsThemeEditorOpen)));
Expand Down Expand Up @@ -364,6 +375,30 @@ public PreferencesViewModel() {
Preferences.Default.ClearCacheOnQuit = index;
Preferences.Save();
});
this.WhenAnyValue(vm => vm.McpEnabled)
.Skip(1)
.Subscribe(enabled => {
Preferences.Default.McpEnabled = enabled;
Preferences.Save();
if (!enabled) {
McpService.Stop();
}
});
this.WhenAnyValue(vm => vm.McpStartupMode)
.Subscribe(mode => {
Preferences.Default.McpStartupMode = mode;
Preferences.Save();
});
this.WhenAnyValue(vm => vm.McpBindAddress)
.Subscribe(address => {
Preferences.Default.McpBindAddress = address;
Preferences.Save();
});
this.WhenAnyValue(vm => vm.McpPort)
.Subscribe(port => {
Preferences.Default.McpPort = port;
Preferences.Save();
});
this.WhenAnyValue(vm => vm.DiffSingerSteps)
.Subscribe(index => {
Preferences.Default.DiffSingerSteps = index;
Expand Down
Loading