Share Dings MCP across agent sessions
This commit is contained in:
parent
e02faf6c92
commit
9051f4b6db
7 changed files with 187 additions and 10 deletions
41
scripts/install-dings-mcp-task.ps1
Normal file
41
scripts/install-dings-mcp-task.ps1
Normal 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
|
||||||
39
tests/AquaSynth.Dsl.Tests/AgentInstrumentRegistryTests.cs
Normal file
39
tests/AquaSynth.Dsl.Tests/AgentInstrumentRegistryTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
tools/AquaSynthDingsMcp/AgentInstrumentRegistry.cs
Normal file
37
tools/AquaSynthDingsMcp/AgentInstrumentRegistry.cs
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
|
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
|
||||||
<ProjectReference Include="..\AquaSynthDingsHost\AquaSynthDingsHost.csproj" ReferenceOutputAssembly="false" />
|
<ProjectReference Include="..\AquaSynthDingsHost\AquaSynthDingsHost.csproj" ReferenceOutputAssembly="false" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
|
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,24 @@ using AquaSynth.Dings;
|
||||||
namespace AquaSynthDingsMcp;
|
namespace AquaSynthDingsMcp;
|
||||||
|
|
||||||
[McpServerToolType]
|
[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(
|
public Task<object> PlayDing(
|
||||||
|
McpServer server,
|
||||||
[Description("Semantic event such as task.complete or input.required.")] string eventId,
|
[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,
|
[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.")]
|
[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();
|
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.")]
|
[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);
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using AquaSynthDingsMcp;
|
using AquaSynthDingsMcp;
|
||||||
using AquaSynth.Dings;
|
using AquaSynth.Dings;
|
||||||
|
|
||||||
|
|
@ -29,10 +31,33 @@ if (args is ["--mute", var muteText] && bool.TryParse(muteText, out var muted))
|
||||||
return;
|
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);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Services.AddSingleton<IDingsHostClient, DingsHostClient>();
|
builder.Services.AddSingleton<IDingsHostClient, DingsHostClient>();
|
||||||
builder.Services.AddSingleton<DingService>();
|
builder.Services.AddSingleton<DingService>();
|
||||||
|
builder.Services.AddSingleton<AgentInstrumentRegistry>();
|
||||||
builder.Services.AddMcpServer()
|
builder.Services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStdioServerTransport()
|
||||||
.WithTools<DingTools>();
|
.WithTools<DingTools>();
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,27 @@ All MCP instances are command bridges. One persistent
|
||||||
Aqua synthesis child, mixer, master volume, and mute state. Windows Volume
|
Aqua synthesis child, mixer, master volume, and mute state. Windows Volume
|
||||||
Mixer and MCP volume tools address the same session.
|
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
|
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
|
means one resident .NET bridge per client even though audio authority is already
|
||||||
shared by `AquaSynthDingsHost.exe`. Keep the bridge disabled in that topology,
|
shared by `AquaSynthDingsHost.exe`. Run one supervised Streamable HTTP MCP server
|
||||||
use the direct CLI for local smokes, or expose one supervised Streamable HTTP MCP
|
instead and point every agent at its loopback endpoint:
|
||||||
server when notifications need to be available across many concurrent agents.
|
|
||||||
|
```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
|
```powershell
|
||||||
dotnet run --project tools/AquaSynthDingsMcp
|
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
|
dotnet run --project tools/AquaSynthDingsMcp -- --mute true
|
||||||
```
|
```
|
||||||
|
|
||||||
MCP tools are `play_ding`, `list_ding_events`, `list_instruments`,
|
MCP tools are `play_ding`, `get_agent_instrument`, `claim_instrument`,
|
||||||
`stop_dings`, `get_volume`, `set_volume`, and `mute_dings`.
|
`list_ding_events`, `list_instruments`, `stop_dings`, `get_volume`,
|
||||||
|
`set_volume`, and `mute_dings`.
|
||||||
|
|
||||||
Instrument availability is catalog-only. Additions must satisfy mechanical
|
Instrument availability is catalog-only. Additions must satisfy mechanical
|
||||||
curation checks and then survive listening/identification tests; see
|
curation checks and then survive listening/identification tests; see
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue