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="..\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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue