diff --git a/scripts/install-dings-mcp-task.ps1 b/scripts/install-dings-mcp-task.ps1 new file mode 100644 index 0000000..bcffd44 --- /dev/null +++ b/scripts/install-dings-mcp-task.ps1 @@ -0,0 +1,41 @@ +[CmdletBinding()] +param( + [string] $TaskName = 'GameCult AquaSynth Dings MCP', + [string] $ListenUrl = 'http://127.0.0.1:17878' +) + +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$executable = Join-Path $repositoryRoot 'tools\AquaSynthDingsMcp\bin\Release\net10.0\AquaSynthDingsMcp.exe' +if (-not (Test-Path -LiteralPath $executable)) { + throw "AquaSynthDingsMcp.exe is not built: $executable" +} + +$action = New-ScheduledTaskAction ` + -Execute $executable ` + -Argument "--http $ListenUrl" ` + -WorkingDirectory $repositoryRoot +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -MultipleInstances IgnoreNew ` + -StartWhenAvailable +$principal = New-ScheduledTaskPrincipal ` + -UserId $env:USERNAME ` + -LogonType Interactive ` + -RunLevel Limited + +Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Description 'One shared loopback Streamable HTTP MCP endpoint for AquaSynth agent notifications.' ` + -Force | Out-Null + +Start-ScheduledTask -TaskName $TaskName +Get-ScheduledTask -TaskName $TaskName | Get-ScheduledTaskInfo diff --git a/tests/AquaSynth.Dsl.Tests/AgentInstrumentRegistryTests.cs b/tests/AquaSynth.Dsl.Tests/AgentInstrumentRegistryTests.cs new file mode 100644 index 0000000..85d87f5 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/AgentInstrumentRegistryTests.cs @@ -0,0 +1,39 @@ +using AquaSynth.Dings; +using AquaSynthDingsMcp; + +namespace AquaSynth.Dsl.Tests; + +public sealed class AgentInstrumentRegistryTests +{ + [Fact] + public void Automatic_assignment_is_stable_for_an_agent_session() + { + var registry = new AgentInstrumentRegistry(); + + var first = registry.GetOrAssign("agent-session-one"); + var second = registry.GetOrAssign("agent-session-one"); + + Assert.Equal(first, second); + Assert.Contains(first, DingCatalog.Instruments.Keys); + } + + [Fact] + public void Agent_can_claim_its_own_instrument() + { + var registry = new AgentInstrumentRegistry(); + registry.GetOrAssign("agent-session-one"); + + var claimed = registry.Claim("agent-session-one", "glass-chime"); + + Assert.Equal("glass-chime", claimed); + Assert.Equal("glass-chime", registry.GetOrAssign("agent-session-one")); + } + + [Fact] + public void Unknown_instrument_cannot_be_claimed() + { + var registry = new AgentInstrumentRegistry(); + + Assert.Throws(() => registry.Claim("agent-session-one", "kazoo-of-regret")); + } +} diff --git a/tools/AquaSynthDingsMcp/AgentInstrumentRegistry.cs b/tools/AquaSynthDingsMcp/AgentInstrumentRegistry.cs new file mode 100644 index 0000000..2d9e894 --- /dev/null +++ b/tools/AquaSynthDingsMcp/AgentInstrumentRegistry.cs @@ -0,0 +1,37 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using AquaSynth.Dings; + +namespace AquaSynthDingsMcp; + +public sealed class AgentInstrumentRegistry +{ + private readonly ConcurrentDictionary assignments = new(StringComparer.Ordinal); + private readonly string[] instrumentIds = DingCatalog.Instruments.Keys.Order(StringComparer.OrdinalIgnoreCase).ToArray(); + + public string GetOrAssign(string agentSessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentSessionId); + return assignments.GetOrAdd(agentSessionId, AssignDeterministically); + } + + public string Claim(string agentSessionId, string preferredInstrumentId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentSessionId); + if (!DingCatalog.Instruments.TryGetValue(preferredInstrumentId, out var instrument)) + { + throw new ArgumentException($"Unknown instrument '{preferredInstrumentId}'."); + } + + assignments[agentSessionId] = instrument.Id; + return instrument.Id; + } + + private string AssignDeterministically(string agentSessionId) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(agentSessionId)); + var slot = BitConverter.ToUInt32(digest, 0) % (uint)instrumentIds.Length; + return instrumentIds[slot]; + } +} diff --git a/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj b/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj index 3fcead4..e4d0e9b 100644 --- a/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj +++ b/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj @@ -9,6 +9,7 @@ + diff --git a/tools/AquaSynthDingsMcp/DingTools.cs b/tools/AquaSynthDingsMcp/DingTools.cs index 6680398..f89b6ec 100644 --- a/tools/AquaSynthDingsMcp/DingTools.cs +++ b/tools/AquaSynthDingsMcp/DingTools.cs @@ -5,14 +5,24 @@ using AquaSynth.Dings; namespace AquaSynthDingsMcp; [McpServerToolType] -public sealed class DingTools(DingService service) +public sealed class DingTools(DingService service, AgentInstrumentRegistry instruments) { - [McpServerTool, Description("Synthesizes and audibly plays a semantic notification motif. Event says what happened; instrument identifies the agent by timbre.")] + [McpServerTool, Description("Synthesizes and audibly plays a semantic notification motif using the stable instrument owned by this agent session.")] public Task PlayDing( + McpServer server, [Description("Semantic event such as task.complete or input.required.")] string eventId, - [Description("Registered timbre such as warm-bell or glass-chime.")] string instrumentId, [Description("Playback gain from 0 to 1.")] float gain = .7f, - CancellationToken cancellationToken = default) => service.PlayAsync(eventId, instrumentId, gain, cancellationToken); + CancellationToken cancellationToken = default) => + service.PlayAsync(eventId, instruments.GetOrAssign(AgentSessionId(server)), gain, cancellationToken); + + [McpServerTool, Description("Returns the stable instrument assigned to this agent session, assigning one automatically if needed.")] + public object GetAgentInstrument(McpServer server) => Describe(instruments.GetOrAssign(AgentSessionId(server))); + + [McpServerTool, Description("Lets this agent session claim a preferred registered instrument. Future play_ding calls use it automatically.")] + public object ClaimInstrument( + McpServer server, + [Description("Registered timbre such as warm-bell or glass-chime.")] string preferredInstrumentId) => + Describe(instruments.Claim(AgentSessionId(server), preferredInstrumentId)); [McpServerTool, Description("Lists the semantic notification events and their meanings.")] public object ListDingEvents() => DingCatalog.Events.Values.Select(x => new { x.Id, x.Meaning, noteCount = x.Notes.Count }).ToArray(); @@ -31,4 +41,12 @@ public sealed class DingTools(DingService service) [McpServerTool, Description("Mutes or unmutes the global AquaSynth Dings Windows audio session.")] public Task MuteDings(bool muted = true, CancellationToken cancellationToken = default) => service.SetMutedAsync(muted, cancellationToken); + + private static string AgentSessionId(McpServer server) => server.SessionId ?? $"stdio:{Environment.ProcessId}"; + + private static object Describe(string instrumentId) + { + var instrument = DingCatalog.Instruments[instrumentId]; + return new { instrument.Id, instrument.Description, instrument.TimbreFamily }; + } } diff --git a/tools/AquaSynthDingsMcp/Program.cs b/tools/AquaSynthDingsMcp/Program.cs index c203baa..4dccffb 100644 --- a/tools/AquaSynthDingsMcp/Program.cs +++ b/tools/AquaSynthDingsMcp/Program.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using AquaSynthDingsMcp; using AquaSynth.Dings; @@ -29,10 +31,33 @@ if (args is ["--mute", var muteText] && bool.TryParse(muteText, out var muted)) return; } +if (args is ["--http"] or ["--http", _]) +{ + var listenUrl = args.Length == 2 ? args[1] : "http://127.0.0.1:17878"; + using var singleton = new Mutex(initiallyOwned: true, "Local\\GameCult.AquaSynthDings.McpHttp.v1", out var ownsMutex); + if (!ownsMutex) return; + + var webBuilder = WebApplication.CreateBuilder([]); + webBuilder.Logging.ClearProviders(); + webBuilder.WebHost.UseUrls(listenUrl); + webBuilder.Services.AddSingleton(); + webBuilder.Services.AddSingleton(); + webBuilder.Services.AddSingleton(); + webBuilder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = false) + .WithTools(); + + var app = webBuilder.Build(); + app.MapMcp("/mcp"); + await app.RunAsync(); + return; +} + var builder = Host.CreateApplicationBuilder(args); builder.Logging.ClearProviders(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithStdioServerTransport() .WithTools(); diff --git a/tools/AquaSynthDingsMcp/README.md b/tools/AquaSynthDingsMcp/README.md index bfb09e5..b1fbe8f 100644 --- a/tools/AquaSynthDingsMcp/README.md +++ b/tools/AquaSynthDingsMcp/README.md @@ -8,12 +8,27 @@ All MCP instances are command bridges. One persistent Aqua synthesis child, mixer, master volume, and mute state. Windows Volume Mixer and MCP volume tools address the same session. -Do not register this STDIO bridge globally in an agent harness that creates a +Do not register the STDIO bridge globally in an agent harness that creates a separate MCP client for every root agent, subagent, or critic. STDIO ownership means one resident .NET bridge per client even though audio authority is already -shared by `AquaSynthDingsHost.exe`. Keep the bridge disabled in that topology, -use the direct CLI for local smokes, or expose one supervised Streamable HTTP MCP -server when notifications need to be available across many concurrent agents. +shared by `AquaSynthDingsHost.exe`. Run one supervised Streamable HTTP MCP server +instead and point every agent at its loopback endpoint: + +```powershell +AquaSynthDingsMcp.exe --http http://127.0.0.1:17878 +``` + +The HTTP server is machine-singleton and keeps only MCP session-to-instrument +assignments. Each agent session is assigned a stable timbre automatically and +may claim another with `claim_instrument`; `play_ding` then uses that assignment +without an instrument argument. `AquaSynthDingsHost.exe` remains the sole +audio-session and playback owner. + +Install the per-user singleton on Windows: + +```powershell +.\scripts\install-dings-mcp-task.ps1 +``` ```powershell dotnet run --project tools/AquaSynthDingsMcp @@ -28,8 +43,9 @@ dotnet run --project tools/AquaSynthDingsMcp -- --set-volume 0.5 dotnet run --project tools/AquaSynthDingsMcp -- --mute true ``` -MCP tools are `play_ding`, `list_ding_events`, `list_instruments`, -`stop_dings`, `get_volume`, `set_volume`, and `mute_dings`. +MCP tools are `play_ding`, `get_agent_instrument`, `claim_instrument`, +`list_ding_events`, `list_instruments`, `stop_dings`, `get_volume`, +`set_volume`, and `mute_dings`. Instrument availability is catalog-only. Additions must satisfy mechanical curation checks and then survive listening/identification tests; see