Share Dings MCP across agent sessions

This commit is contained in:
Meta Vens 2026-08-21 21:18:45 +02:00
commit 9051f4b6db
7 changed files with 187 additions and 10 deletions

View file

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

View file

@ -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<ArgumentException>(() => registry.Claim("agent-session-one", "kazoo-of-regret"));
}
}

View file

@ -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<string, string> 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];
}
}

View file

@ -9,6 +9,7 @@
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
<ProjectReference Include="..\AquaSynthDingsHost\AquaSynthDingsHost.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
</ItemGroup>
</Project>

View file

@ -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<object> 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<DingsResponse> 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 };
}
}

View file

@ -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<IDingsHostClient, DingsHostClient>();
webBuilder.Services.AddSingleton<DingService>();
webBuilder.Services.AddSingleton<AgentInstrumentRegistry>();
webBuilder.Services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = false)
.WithTools<DingTools>();
var app = webBuilder.Build();
app.MapMcp("/mcp");
await app.RunAsync();
return;
}
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddSingleton<IDingsHostClient, DingsHostClient>();
builder.Services.AddSingleton<DingService>();
builder.Services.AddSingleton<AgentInstrumentRegistry>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<DingTools>();

View file

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