From 8ec2efa7d094ce3fb87d2c9b911cec4bb3eaa017 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:31:17 +0000 Subject: [PATCH 1/6] try: MCP attempt 2 --- .../Infrastructure/Cli/CliParser.cs | 7 + .../Infrastructure/Cli/McpOptions.cs | 10 ++ .../Mcp/McpInitializer.cs | 25 +++ src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs | 82 ++++++++++ src/Queil.Ring.DotNet.Cli/Program.cs | 122 ++++++++++++++- .../Queil.Ring.DotNet.Cli.csproj | 2 + .../Workspace/IWorkspaceLauncher.cs | 1 + .../Workspace/WorkspaceLauncher.cs | 7 +- src/Queil.Ring.DotNet.Cli/packages.lock.json | 39 ++++- tests/Ring.Tests.Integration/McpClient.fs | 148 ++++++++++++++++++ .../Ring.Tests.Integration.fsproj | 2 + tests/Ring.Tests.Integration/Tests.Mcp.fs | 135 ++++++++++++++++ 12 files changed, 565 insertions(+), 15 deletions(-) create mode 100644 src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs create mode 100644 src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs create mode 100644 src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs create mode 100644 tests/Ring.Tests.Integration/McpClient.fs create mode 100644 tests/Ring.Tests.Integration/Tests.Mcp.fs diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs index 9c66c00..70af0f9 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs @@ -43,6 +43,7 @@ void EnsureConfigOverrideFile(string path, string scope) .ParseArguments< ConsoleOptions, HeadlessOptions, + McpOptions, CloneOptions, ConfigPath, ConfigDump, @@ -53,6 +54,12 @@ void EnsureConfigOverrideFile(string path, string scope) options = opts; }) .WithParsed(opts => options = opts) + .WithParsed(opts => + { + if (opts.WorkspacePath != null) + opts.WorkspacePath = Path.GetFullPath(opts.WorkspacePath, originalWorkingDir); + options = opts; + }) .WithParsed(opts => { opts.WorkspacePath = WorkspacePathOrDefault(opts.WorkspacePath); diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs new file mode 100644 index 0000000..d937014 --- /dev/null +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs @@ -0,0 +1,10 @@ +namespace Queil.Ring.DotNet.Cli.Infrastructure.Cli; + +using CommandLine; + +[Verb("mcp", HelpText = "Starts ring! as an MCP server using stdio transport")] +public class McpOptions : BaseOptions +{ + [Option('w', "workspace", Required = false, HelpText = "Workspace path to auto-load and start")] + public string? WorkspacePath { get; set; } +} diff --git a/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs new file mode 100644 index 0000000..77ca622 --- /dev/null +++ b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs @@ -0,0 +1,25 @@ +namespace Queil.Ring.DotNet.Cli.Mcp; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Infrastructure; +using Infrastructure.Cli; +using Microsoft.Extensions.Hosting; + +public class McpInitializer(IServer server, McpOptions options, IHostApplicationLifetime lifetime) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var token = lifetime.ApplicationStopping; + await server.InitializeAsync(token); + + if (options.WorkspacePath is { } path) + { + await server.LoadAsync(path, token); + await server.StartAsync(token); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs b/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs new file mode 100644 index 0000000..b0c47b1 --- /dev/null +++ b/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs @@ -0,0 +1,82 @@ +namespace Queil.Ring.DotNet.Cli.Mcp; + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Infrastructure; +using ModelContextProtocol.Server; +using Protocol; +using Protocol.Events; +using Workspace; + +[McpServerToolType] +public class RingMcpTools(IServer server, IWorkspaceLauncher launcher) +{ + [McpServerTool(Name = "get_workspace_info", Title = "Get workspace info")] + public string GetWorkspaceInfo() + { + server.RequestWorkspaceInfo(); + return Encoding.UTF8.GetString(launcher.GetCurrentInfo().Serialize()); + } + + [McpServerTool(Name = "load_workspace", Title = "Load workspace")] + public async Task LoadWorkspace(string workspacePath, CancellationToken ct) + { + await server.LoadAsync(workspacePath, ct); + return "loaded"; + } + + [McpServerTool(Name = "start_workspace", Title = "Start workspace")] + public async Task StartWorkspace(CancellationToken ct) + { + await server.StartAsync(ct); + return "started"; + } + + [McpServerTool(Name = "stop_workspace", Title = "Stop workspace")] + public async Task StopWorkspace(CancellationToken ct) + { + await server.StopAsync(ct); + return "stopped"; + } + + [McpServerTool(Name = "unload_workspace", Title = "Unload workspace")] + public async Task UnloadWorkspace(CancellationToken ct) + { + await server.UnloadAsync(ct); + return "unloaded"; + } + + [McpServerTool(Name = "include_runnable", Title = "Include runnable")] + public async Task IncludeRunnable(string id, CancellationToken ct) + { + var ack = await server.IncludeAsync(id, ct); + return ack == Ack.NotFound ? $"not found: {id}" : "included"; + } + + [McpServerTool(Name = "exclude_runnable", Title = "Exclude runnable")] + public async Task ExcludeRunnable(string id, CancellationToken ct) + { + var ack = await server.ExcludeAsync(id, ct); + return ack == Ack.NotFound ? $"not found: {id}" : "excluded"; + } + + [McpServerTool(Name = "apply_flavour", Title = "Apply flavour")] + public async Task ApplyFlavour(string flavour, CancellationToken ct) + { + var ack = await server.ApplyFlavourAsync(flavour, ct); + return ack == Ack.NotFound ? $"not found: {flavour}" : "applied"; + } + + [McpServerTool(Name = "execute_task", Title = "Execute task")] + public async Task ExecuteTask(string runnableId, string taskId, CancellationToken ct) + { + var ack = await server.ExecuteTaskAsync(new RunnableTask { RunnableId = runnableId, TaskId = taskId }, ct); + return ack switch + { + Ack.NotFound => $"not found: {runnableId}/{taskId}", + Ack.TaskFailed => "task failed", + _ => "ok" + }; + } +} diff --git a/src/Queil.Ring.DotNet.Cli/Program.cs b/src/Queil.Ring.DotNet.Cli/Program.cs index c334037..e132f16 100644 --- a/src/Queil.Ring.DotNet.Cli/Program.cs +++ b/src/Queil.Ring.DotNet.Cli/Program.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using System.Net.Http; using System.Reflection; using System.Threading; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Queil.Ring.Configuration; @@ -21,6 +23,7 @@ using Queil.Ring.DotNet.Cli.Infrastructure; using Queil.Ring.DotNet.Cli.Infrastructure.Cli; using Queil.Ring.DotNet.Cli.Logging; +using Queil.Ring.DotNet.Cli.Mcp; using Queil.Ring.DotNet.Cli.Tools; using Queil.Ring.DotNet.Cli.Tools.Windows; using Queil.Ring.DotNet.Cli.Workspace; @@ -37,13 +40,19 @@ static string Ring(string ver) => try { + var options = CliParser.GetOptions(args, originalWorkingDir); + + if (options is McpOptions mcpOptions) + { + await RunMcpServerAsync(mcpOptions); + return; + } + ThreadPool.SetMinThreads(100, 100); Log.Logger = new LoggerConfiguration().WriteTo.Console() .MinimumLevel.Information() .MinimumLevel.Override("Microsoft", LogEventLevel.Error).CreateLogger(); - var options = CliParser.GetOptions(args, originalWorkingDir); - if (!options.NoLogo) { var version = Assembly.GetExecutingAssembly().GetName().Version!; @@ -186,3 +195,110 @@ await app.Services.GetRequiredService().StartAsync(app.Lifetime.A { Directory.SetCurrentDirectory(originalWorkingDir); } + +async Task RunMcpServerAsync(McpOptions opts) +{ + Log.Logger = new LoggerConfiguration() + .WriteTo.File(Path.Combine(Path.GetTempPath(), "ring-mcp.log")) + .MinimumLevel.Warning() + .CreateLogger(); + + var containerOptions = new ContainerOptions { EnablePropertyInjection = false, EnableVariance = false }; + IServiceContainer mcpContainer = new ServiceContainer(containerOptions) + { + ScopeManagerProvider = new PerLogicalCallContextScopeManagerProvider() + }; + var clients = new ConcurrentDictionary(); + + var hostBuilder = Host.CreateDefaultBuilder(args); + + hostBuilder.UseServiceProviderFactory(new LightInjectServiceProviderFactory()); + + hostBuilder.ConfigureAppConfiguration((ctx, config) => + { + config.Sources.Clear(); + config.AddTomlFile(InstallationDir.SettingsPath, false); + config.AddTomlFile(InstallationDir.LoggingPath, false); + config.AddTomlFile(UserSettingsDir.SettingsPath, true); + config.AddTomlFile(Directories.Working(originalWorkingDir).SettingsPath, true); + config.AddEnvironmentVariables("RING_"); + }); + + hostBuilder.ConfigureServices((ctx, services) => + { + services.AddSingleton(opts); + services.AddSingleton(opts); + services.AddSingleton>(_ => + uri => clients.GetOrAdd(uri, new HttpClient { BaseAddress = uri, MaxResponseContentBufferSize = 1 })); + services.AddOptions(); + services.Configure(ctx.Configuration); + services.AddLogging(loggingBuilder => loggingBuilder.AddSerilog(dispose: true)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(f => f.GetRequiredService()); + services.AddSingleton(f => f.GetRequiredService()); + services.AddSingleton(f => + { + var configuredPath = f.GetRequiredService>().Value.Kubernetes.ConfigPath; + var maybeKubeconfigEnv = Environment.GetEnvironmentVariable("KUBECONFIG"); + var configPath = maybeKubeconfigEnv ?? configuredPath; + if (configPath == null) return (Kubernetes)null!; + return new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath)); + }); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddHostedService(); + services.AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + }); + + hostBuilder.ConfigureContainer((ctx, container) => + { + var runnableTypes = Assembly.GetEntryAssembly()!.GetExportedTypes() + .Where(t => typeof(IRunnable).IsAssignableFrom(t)).ToList(); + + var configMap = (from r in runnableTypes + let cfg = r.GetProperty(nameof(Runnable.Config)) + where cfg != null + select (RunnableType: r, ConfigType: cfg.PropertyType)) + .ToDictionary(x => x.ConfigType, x => x.RunnableType); + + foreach (var (_, rt) in configMap) container.Register(rt, rt, new PerRequestLifeTime()); + + container.Register((factory, cfg) => + { + var ct = configMap[cfg.GetType()]; + var ctor = ct.GetConstructors().Single(); + var factoryArgs = ctor.GetParameters().Select(x => + typeof(IRunnableConfig).IsAssignableFrom(x.ParameterType) + ? cfg + : factory.GetInstance(x.ParameterType)) + .ToArray(); + return (IRunnable)ctor.Invoke(factoryArgs); + }); + container.Register>(x => x.BeginScope); + }); + + hostBuilder.UseSerilog(); + + var host = hostBuilder.Build(); + + var loggingConfig = new LoggerConfiguration() + .ReadFrom.Configuration(host.Services.GetRequiredService()); + if (opts.IsDebug) loggingConfig.MinimumLevel.Debug(); + Log.Logger = loggingConfig.CreateLogger(); + + await host.RunAsync(); +} diff --git a/src/Queil.Ring.DotNet.Cli/Queil.Ring.DotNet.Cli.csproj b/src/Queil.Ring.DotNet.Cli/Queil.Ring.DotNet.Cli.csproj index ff50be1..0d2ca28 100644 --- a/src/Queil.Ring.DotNet.Cli/Queil.Ring.DotNet.Cli.csproj +++ b/src/Queil.Ring.DotNet.Cli/Queil.Ring.DotNet.Cli.csproj @@ -25,7 +25,9 @@ + + diff --git a/src/Queil.Ring.DotNet.Cli/Workspace/IWorkspaceLauncher.cs b/src/Queil.Ring.DotNet.Cli/Workspace/IWorkspaceLauncher.cs index feb6481..90e34d7 100644 --- a/src/Queil.Ring.DotNet.Cli/Workspace/IWorkspaceLauncher.cs +++ b/src/Queil.Ring.DotNet.Cli/Workspace/IWorkspaceLauncher.cs @@ -19,6 +19,7 @@ public interface IWorkspaceLauncher Task IncludeAsync(string id, CancellationToken token); Task ApplyFlavourAsync(string flavour, CancellationToken token); void PublishStatus(ServerState serverState); + WorkspaceInfo GetCurrentInfo(); Task ExecuteTaskAsync(RunnableTask task, CancellationToken token); event EventHandler OnInitiated; } diff --git a/src/Queil.Ring.DotNet.Cli/Workspace/WorkspaceLauncher.cs b/src/Queil.Ring.DotNet.Cli/Workspace/WorkspaceLauncher.cs index 669149d..1e5b10b 100644 --- a/src/Queil.Ring.DotNet.Cli/Workspace/WorkspaceLauncher.cs +++ b/src/Queil.Ring.DotNet.Cli/Workspace/WorkspaceLauncher.cs @@ -152,10 +152,9 @@ public async Task IncludeAsync(string id, CancellationToken token return IncludeResult.Ok; } - public void PublishStatus(ServerState serverState) - { - PublishStatusCore(serverState, true); - } + public void PublishStatus(ServerState serverState) => PublishStatusCore(serverState, true); + + public WorkspaceInfo GetCurrentInfo() => CurrentInfo; public async Task WaitUntilStoppedAsync(CancellationToken token) { diff --git a/src/Queil.Ring.DotNet.Cli/packages.lock.json b/src/Queil.Ring.DotNet.Cli/packages.lock.json index 9178a9e..fcf1a03 100644 --- a/src/Queil.Ring.DotNet.Cli/packages.lock.json +++ b/src/Queil.Ring.DotNet.Cli/packages.lock.json @@ -27,6 +27,15 @@ "LightInject.Microsoft.DependencyInjection": "3.3.6" } }, + "ModelContextProtocol": { + "type": "Direct", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "W7UX8AQ1qMjXyCDcpP25u/L1W2vIIgfhLX/B2ZtTU1VUyILXdmVbdRjkQesKVPT/wPMpYXIHUcZJTPdsGfKSfQ==", + "dependencies": { + "ModelContextProtocol.Core": "1.0.0" + } + }, "Serilog.AspNetCore": { "type": "Direct", "requested": "[9.*, )", @@ -42,6 +51,15 @@ "Serilog.Sinks.File": "6.0.0" } }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==", + "dependencies": { + "Serilog": "4.0.0" + } + }, "Stateless": { "type": "Direct", "requested": "[5.*, )", @@ -75,11 +93,24 @@ "LightInject": "6.4.0" } }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "hDjDvUERvUH3HBMs2MDusOcGJBjAHOG5pJIU2x/HZEa4e1UthNKt89cwMi3B+ogJo6skki1XFjfgGN3ksnVqvQ==" + }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "9.0.0", "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==" }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "QKboiQEq2MJMGeQ029Gy6xqge88abm0Px9lnG7hueOyf+EDCxi5SUATV+Df7GwT+NwWzkEsYG271bUQD+LGhEg==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.3.0" + } + }, "Serilog": { "type": "Transitive", "resolved": "4.2.0", @@ -135,14 +166,6 @@ "Serilog": "4.0.0" } }, - "Serilog.Sinks.File": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==", - "dependencies": { - "Serilog": "4.0.0" - } - }, "Tomlyn": { "type": "Transitive", "resolved": "0.20.0", diff --git a/tests/Ring.Tests.Integration/McpClient.fs b/tests/Ring.Tests.Integration/McpClient.fs new file mode 100644 index 0000000..f765578 --- /dev/null +++ b/tests/Ring.Tests.Integration/McpClient.fs @@ -0,0 +1,148 @@ +namespace Ring.Tests.Integration + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json +open System.Threading.Tasks +open Ring.Tests.Integration.DotNet.Types +open Ring.Tests.Integration.RingControl + +module McpClient = + + type McpProcess(options: Options, ?workspacePath: string) = + let mutable msgId = 0 + let proc = new Process() + + let nextId () = + msgId <- msgId + 1 + msgId + + let sendMessage (msg: string) = + let bytes = Encoding.UTF8.GetBytes(msg) + let header = $"Content-Length: {bytes.Length}\r\n\r\n" + proc.StandardInput.Write(header) + proc.StandardInput.Write(msg) + proc.StandardInput.Flush() + + let readMessage () = + task { + let mutable contentLength = 0 + let mutable line = proc.StandardOutput.ReadLine() + while line <> null && line <> "" do + if line.StartsWith("Content-Length:") then + contentLength <- int (line.Substring("Content-Length:".Length).Trim()) + line <- proc.StandardOutput.ReadLine() + if contentLength > 0 then + let buf = Array.zeroCreate contentLength + let! _ = proc.StandardOutput.ReadAsync(buf, 0, contentLength) |> Async.AwaitTask + return String(buf) + else + return "" + } + + let callRpc (method: string) (paramsObj: obj) = + task { + let id = nextId () + let msg = + JsonSerializer.Serialize( + {| jsonrpc = "2.0" + id = id + method = method + ``params`` = paramsObj |} + ) + sendMessage msg + let! response = readMessage () + return JsonDocument.Parse(response) + } + + member _.Start() = + let name, cmdArgs = + match options.LocalTool with + | None -> "ring", [ "mcp"; "--no-logo" ] + | Some _ -> "dotnet", [ "ring"; "mcp"; "--no-logo" ] + + let allArgs = + cmdArgs + @ (match workspacePath with + | Some p -> [ "--workspace"; p ] + | None -> []) + + proc.StartInfo <- ProcessStartInfo(name, allArgs) + proc.StartInfo.UseShellExecute <- false + proc.StartInfo.RedirectStandardInput <- true + proc.StartInfo.RedirectStandardOutput <- true + proc.StartInfo.WorkingDirectory <- options.WorkingDir + + for k, v in options.Env do + proc.StartInfo.EnvironmentVariables[k] <- v + + proc.Start() |> ignore + + member _.Initialize() = + task { + let! _ = + callRpc + "initialize" + {| protocolVersion = "2024-11-05" + capabilities = {||} + clientInfo = {| name = "ring-test"; version = "1.0" |} |} + + sendMessage + (JsonSerializer.Serialize( + {| jsonrpc = "2.0" + method = "notifications/initialized" + ``params`` = {||} |} + )) + } + + member _.ListTools() = + task { + let! doc = callRpc "tools/list" {||} + return + doc.RootElement + .GetProperty("result") + .GetProperty("tools") + .EnumerateArray() + |> Seq.map (fun t -> t.GetProperty("name").GetString()) + |> Seq.toList + } + + member _.CallTool(name: string, ?args: (string * string) list) = + task { + let arguments = + match args with + | None -> dict [] + | Some pairs -> pairs |> List.map (fun (k, v) -> k, v :> obj) |> dict + + let! doc = + callRpc + "tools/call" + {| name = name + arguments = arguments |} + + let result = doc.RootElement.GetProperty("result") + return + result.GetProperty("content").EnumerateArray() + |> Seq.map (fun c -> c.GetProperty("text").GetString()) + |> String.concat "" + } + + interface IAsyncDisposable with + member _.DisposeAsync() = + try + if not proc.HasExited then proc.Kill() + proc.Dispose() + with _ -> + () + ValueTask.CompletedTask + + interface IDisposable with + member this.Dispose() = + (this :> IAsyncDisposable).DisposeAsync().GetAwaiter().GetResult() + + type Ring with + + member x.McpProcess(?workspacePath: string) = + new McpProcess(x.Options, ?workspacePath = workspacePath) diff --git a/tests/Ring.Tests.Integration/Ring.Tests.Integration.fsproj b/tests/Ring.Tests.Integration/Ring.Tests.Integration.fsproj index 20aca43..d80d30d 100644 --- a/tests/Ring.Tests.Integration/Ring.Tests.Integration.fsproj +++ b/tests/Ring.Tests.Integration/Ring.Tests.Integration.fsproj @@ -16,10 +16,12 @@ + + diff --git a/tests/Ring.Tests.Integration/Tests.Mcp.fs b/tests/Ring.Tests.Integration/Tests.Mcp.fs new file mode 100644 index 0000000..8dc3830 --- /dev/null +++ b/tests/Ring.Tests.Integration/Tests.Mcp.fs @@ -0,0 +1,135 @@ +module Ring.Tests.Integration.Mcp + +open System.Diagnostics +open System.Threading.Tasks +open Expecto +open Ring.Tests.Integration.McpClient +open Ring.Tests.Integration.RingControl +open Ring.Tests.Integration.Shared +open Ring.Tests.Integration.TestContext + +let private expectedTools = + [ "apply_flavour" + "execute_task" + "exclude_runnable" + "get_workspace_info" + "include_runnable" + "load_workspace" + "start_workspace" + "stop_workspace" + "unload_workspace" ] + +let private pollUntil (timeoutMs: int) (condition: string -> bool) (poll: unit -> Task) = + task { + let sw = Stopwatch.StartNew() + let mutable ok = false + + while not ok && sw.ElapsedMilliseconds < int64 timeoutMs do + let! info = poll () + + if condition info then + ok <- true + else + do! Task.Delay 500 + + return ok + } + +[] +let tests = + testList + "MCP tests" + [ testTask "tools/list registers all ring tools" { + use ctx = new TestContext(localOptions) + let! (ring: Ring, _) = ctx.Init() + let mcp = ring.McpProcess() + use _mcp = mcp + mcp.Start() + do! mcp.Initialize() + let! tools = mcp.ListTools() + + for name in expectedTools do + $"Tool '{name}' should be listed" |> Expect.contains tools name + } + + testTask "get_workspace_info returns IDLE state before any workspace is loaded" { + use ctx = new TestContext(localOptions) + let! (ring: Ring, _) = ctx.Init() + let mcp = ring.McpProcess() + use _mcp = mcp + mcp.Start() + do! mcp.Initialize() + let! (info: string) = mcp.CallTool("get_workspace_info") + "Should report IDLE server state" |> Expect.isTrue (info.Contains("IDLE")) + } + + testTask "load_workspace + start_workspace makes services healthy" { + use ctx = new TestContext(localOptions) + let! (ring: Ring, dir: TestDir) = ctx.Init() + let workspace = dir.InSourceDir "../resources/basic/proc.toml" + let mcp = ring.McpProcess() + use _mcp = mcp + mcp.Start() + do! mcp.Initialize() + + let! (loadResult: string) = mcp.CallTool("load_workspace", [ "workspacePath", workspace ]) + "Load should succeed" |> Expect.isTrue (loadResult.Contains("loaded")) + + let! (startResult: string) = mcp.CallTool("start_workspace") + "Start should succeed" |> Expect.isTrue (startResult.Contains("started")) + + let! ok = + pollUntil + 30000 + (fun info -> info.Contains("HEALTHY")) + (fun () -> mcp.CallTool "get_workspace_info") + + "Workspace should reach HEALTHY state" |> Expect.isTrue ok + + let! (info: string) = mcp.CallTool("get_workspace_info") + "Should show proc-1" |> Expect.isTrue (info.Contains("proc-1")) + "Should show proc-2" |> Expect.isTrue (info.Contains("proc-2")) + } + + testTask "exclude_runnable drops a service to ZERO state" { + use ctx = new TestContext(localOptions) + let! (ring: Ring, dir: TestDir) = ctx.Init() + let workspace = dir.InSourceDir "../resources/basic/proc.toml" + let mcp = ring.McpProcess() + use _mcp = mcp + mcp.Start() + do! mcp.Initialize() + let! _ = mcp.CallTool("load_workspace", [ "workspacePath", workspace ]) + let! _ = mcp.CallTool("start_workspace") + let! _ = pollUntil 30000 (fun info -> info.Contains("HEALTHY")) (fun () -> mcp.CallTool "get_workspace_info") + + let! (excludeResult: string) = mcp.CallTool("exclude_runnable", [ "id", "proc-1" ]) + "Exclude should succeed" |> Expect.isTrue (excludeResult.Contains("excluded")) + + let! ok = + pollUntil + 10000 + (fun info -> info.Contains("\"ZERO\"")) + (fun () -> mcp.CallTool "get_workspace_info") + + "proc-1 should reach ZERO state after exclusion" |> Expect.isTrue ok + } + + testTask "auto-loads and starts workspace when --workspace flag is provided" { + use ctx = new TestContext(localOptions) + let! (ring: Ring, dir: TestDir) = ctx.Init() + let workspace = dir.InSourceDir "../resources/basic/proc.toml" + let mcp = ring.McpProcess(workspacePath = workspace) + use _mcp = mcp + mcp.Start() + do! mcp.Initialize() + + let! ok = + pollUntil + 30000 + (fun info -> info.Contains("proc-1") && info.Contains("proc-2")) + (fun () -> mcp.CallTool "get_workspace_info") + + "Workspace should be auto-loaded with both procs visible" |> Expect.isTrue ok + } ] + |> testLabel "mcp" From aba0589cfc75feb3e98ecbb811fe7e99d64ce73d Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:18:31 +0000 Subject: [PATCH 2/6] reimplement --- .../Infrastructure/Cli/BaseOptions.cs | 3 + .../Infrastructure/Cli/CliParser.cs | 7 - .../Infrastructure/Cli/McpOptions.cs | 10 -- .../Infrastructure/Server.cs | 3 + .../Mcp/McpInitializer.cs | 5 +- src/Queil.Ring.DotNet.Cli/Program.cs | 164 +++++------------- .../Properties/launchSettings.json | 5 +- tests/Ring.Tests.Integration/McpClient.fs | 30 ++-- 8 files changed, 60 insertions(+), 167 deletions(-) delete mode 100644 src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/BaseOptions.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/BaseOptions.cs index df9d5af..fa95fa1 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/BaseOptions.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/BaseOptions.cs @@ -6,6 +6,9 @@ public class ServeOptions : BaseOptions { [Option('p', "port", Default = 7999, Required = false, HelpText = "Specify sever port (defaults to 7999)")] public int Port { get; set; } + + [Option("mcp", Required = false, HelpText = "Enable MCP server via stdio transport")] + public bool Mcp { get; set; } } public class BaseOptions diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs index 70af0f9..9c66c00 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/CliParser.cs @@ -43,7 +43,6 @@ void EnsureConfigOverrideFile(string path, string scope) .ParseArguments< ConsoleOptions, HeadlessOptions, - McpOptions, CloneOptions, ConfigPath, ConfigDump, @@ -54,12 +53,6 @@ void EnsureConfigOverrideFile(string path, string scope) options = opts; }) .WithParsed(opts => options = opts) - .WithParsed(opts => - { - if (opts.WorkspacePath != null) - opts.WorkspacePath = Path.GetFullPath(opts.WorkspacePath, originalWorkingDir); - options = opts; - }) .WithParsed(opts => { opts.WorkspacePath = WorkspacePathOrDefault(opts.WorkspacePath); diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs deleted file mode 100644 index d937014..0000000 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/McpOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Queil.Ring.DotNet.Cli.Infrastructure.Cli; - -using CommandLine; - -[Verb("mcp", HelpText = "Starts ring! as an MCP server using stdio transport")] -public class McpOptions : BaseOptions -{ - [Option('w', "workspace", Required = false, HelpText = "Workspace path to auto-load and start")] - public string? WorkspacePath { get; set; } -} diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Server.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Server.cs index 552af71..c2347c1 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Server.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Server.cs @@ -24,9 +24,12 @@ public class Server( { private readonly ServerFsm _fsm = new(); private Scope? _scope; + private bool _initialized; public Task InitializeAsync(CancellationToken token) { + if (_initialized) return Task.CompletedTask; + _initialized = true; _fsm.Configure(State.Idle) .OnEntryFromAsync(Trigger.Unload, async () => { diff --git a/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs index 77ca622..57ff421 100644 --- a/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs +++ b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs @@ -1,20 +1,19 @@ namespace Queil.Ring.DotNet.Cli.Mcp; -using System; using System.Threading; using System.Threading.Tasks; using Infrastructure; using Infrastructure.Cli; using Microsoft.Extensions.Hosting; -public class McpInitializer(IServer server, McpOptions options, IHostApplicationLifetime lifetime) : IHostedService +public class McpInitializer(IServer server, BaseOptions options, IHostApplicationLifetime lifetime) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { var token = lifetime.ApplicationStopping; await server.InitializeAsync(token); - if (options.WorkspacePath is { } path) + if (options is ConsoleOptions { WorkspacePath: { } path }) { await server.LoadAsync(path, token); await server.StartAsync(token); diff --git a/src/Queil.Ring.DotNet.Cli/Program.cs b/src/Queil.Ring.DotNet.Cli/Program.cs index e132f16..be6f475 100644 --- a/src/Queil.Ring.DotNet.Cli/Program.cs +++ b/src/Queil.Ring.DotNet.Cli/Program.cs @@ -15,7 +15,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Queil.Ring.Configuration; @@ -41,19 +40,23 @@ static string Ring(string ver) => try { var options = CliParser.GetOptions(args, originalWorkingDir); + var isMcp = options is ServeOptions { Mcp: true }; - if (options is McpOptions mcpOptions) - { - await RunMcpServerAsync(mcpOptions); - return; - } + if (isMcp) Console.SetOut(TextWriter.Null); ThreadPool.SetMinThreads(100, 100); - Log.Logger = new LoggerConfiguration().WriteTo.Console() - .MinimumLevel.Information() - .MinimumLevel.Override("Microsoft", LogEventLevel.Error).CreateLogger(); - - if (!options.NoLogo) + Log.Logger = isMcp + ? new LoggerConfiguration() + .WriteTo.File(Path.Combine(Path.GetTempPath(), "ring-mcp.log")) + .MinimumLevel.Warning() + .CreateLogger() + : new LoggerConfiguration() + .WriteTo.Console() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Error) + .CreateLogger(); + + if (!options.NoLogo && !isMcp) { var version = Assembly.GetExecutingAssembly().GetName().Version!; Console.WriteLine(Ring(version.ToString())); @@ -76,7 +79,7 @@ static string Ring(string ver) => var services = builder.Services; services.AddSingleton(options); - if (options is ServeOptions) services.AddSingleton(f => (ServeOptions)f.GetRequiredService()); + if (options is ServeOptions so) services.AddSingleton(so); services.AddSingleton>(_ => uri => clients.GetOrAdd(uri, new HttpClient { BaseAddress = uri, MaxResponseContentBufferSize = 1 })); services.AddOptions(); @@ -97,13 +100,25 @@ static string Ring(string ver) => { var configuredPath = f.GetRequiredService>().Value.Kubernetes.ConfigPath; var maybeKubeconfigEnv = Environment.GetEnvironmentVariable("KUBECONFIG"); - var configPath = maybeKubeconfigEnv ?? - configuredPath ?? throw new InvalidOperationException("Kubernetes config path is not set"); + var configPath = maybeKubeconfigEnv ?? configuredPath; + if (!isMcp && configPath == null) throw new InvalidOperationException("Kubernetes config path is not set"); + if (configPath == null) return (Kubernetes)null!; return new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath)); }); services.AddHostedService(); - services.AddSingleton(); + + if (isMcp) + { + services.AddHostedService(); + services.AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + } + else + { + services.AddSingleton(); + } services.AddTransient(); services.AddTransient(); @@ -175,9 +190,12 @@ static string Ring(string ver) => app.UseWebSockets(); app.UseMiddleware(); - app.Lifetime.ApplicationStarted.Register(async () => - await app.Services.GetRequiredService().StartAsync(app.Lifetime.ApplicationStopping) - ); + if (!isMcp) + { + app.Lifetime.ApplicationStarted.Register(async () => + await app.Services.GetRequiredService().StartAsync(app.Lifetime.ApplicationStopping) + ); + } await app.RunRingAsync(); } @@ -186,6 +204,9 @@ await app.Services.GetRequiredService().StartAsync(app.Lifetime.A Console.WriteLine($"ERROR: {x.Message}"); Environment.ExitCode = 1; } +catch (OperationCanceledException) +{ +} catch (Exception ex) { Log.Logger.Fatal($"Unhandled exception: {ex}"); @@ -195,110 +216,3 @@ await app.Services.GetRequiredService().StartAsync(app.Lifetime.A { Directory.SetCurrentDirectory(originalWorkingDir); } - -async Task RunMcpServerAsync(McpOptions opts) -{ - Log.Logger = new LoggerConfiguration() - .WriteTo.File(Path.Combine(Path.GetTempPath(), "ring-mcp.log")) - .MinimumLevel.Warning() - .CreateLogger(); - - var containerOptions = new ContainerOptions { EnablePropertyInjection = false, EnableVariance = false }; - IServiceContainer mcpContainer = new ServiceContainer(containerOptions) - { - ScopeManagerProvider = new PerLogicalCallContextScopeManagerProvider() - }; - var clients = new ConcurrentDictionary(); - - var hostBuilder = Host.CreateDefaultBuilder(args); - - hostBuilder.UseServiceProviderFactory(new LightInjectServiceProviderFactory()); - - hostBuilder.ConfigureAppConfiguration((ctx, config) => - { - config.Sources.Clear(); - config.AddTomlFile(InstallationDir.SettingsPath, false); - config.AddTomlFile(InstallationDir.LoggingPath, false); - config.AddTomlFile(UserSettingsDir.SettingsPath, true); - config.AddTomlFile(Directories.Working(originalWorkingDir).SettingsPath, true); - config.AddEnvironmentVariables("RING_"); - }); - - hostBuilder.ConfigureServices((ctx, services) => - { - services.AddSingleton(opts); - services.AddSingleton(opts); - services.AddSingleton>(_ => - uri => clients.GetOrAdd(uri, new HttpClient { BaseAddress = uri, MaxResponseContentBufferSize = 1 })); - services.AddOptions(); - services.Configure(ctx.Configuration); - services.AddLogging(loggingBuilder => loggingBuilder.AddSerilog(dispose: true)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(f => f.GetRequiredService()); - services.AddSingleton(f => f.GetRequiredService()); - services.AddSingleton(f => - { - var configuredPath = f.GetRequiredService>().Value.Kubernetes.ConfigPath; - var maybeKubeconfigEnv = Environment.GetEnvironmentVariable("KUBECONFIG"); - var configPath = maybeKubeconfigEnv ?? configuredPath; - if (configPath == null) return (Kubernetes)null!; - return new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath)); - }); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - services.AddHostedService(); - services.AddMcpServer() - .WithStdioServerTransport() - .WithTools(); - }); - - hostBuilder.ConfigureContainer((ctx, container) => - { - var runnableTypes = Assembly.GetEntryAssembly()!.GetExportedTypes() - .Where(t => typeof(IRunnable).IsAssignableFrom(t)).ToList(); - - var configMap = (from r in runnableTypes - let cfg = r.GetProperty(nameof(Runnable.Config)) - where cfg != null - select (RunnableType: r, ConfigType: cfg.PropertyType)) - .ToDictionary(x => x.ConfigType, x => x.RunnableType); - - foreach (var (_, rt) in configMap) container.Register(rt, rt, new PerRequestLifeTime()); - - container.Register((factory, cfg) => - { - var ct = configMap[cfg.GetType()]; - var ctor = ct.GetConstructors().Single(); - var factoryArgs = ctor.GetParameters().Select(x => - typeof(IRunnableConfig).IsAssignableFrom(x.ParameterType) - ? cfg - : factory.GetInstance(x.ParameterType)) - .ToArray(); - return (IRunnable)ctor.Invoke(factoryArgs); - }); - container.Register>(x => x.BeginScope); - }); - - hostBuilder.UseSerilog(); - - var host = hostBuilder.Build(); - - var loggingConfig = new LoggerConfiguration() - .ReadFrom.Configuration(host.Services.GetRequiredService()); - if (opts.IsDebug) loggingConfig.MinimumLevel.Debug(); - Log.Logger = loggingConfig.CreateLogger(); - - await host.RunAsync(); -} diff --git a/src/Queil.Ring.DotNet.Cli/Properties/launchSettings.json b/src/Queil.Ring.DotNet.Cli/Properties/launchSettings.json index 533875f..98df085 100644 --- a/src/Queil.Ring.DotNet.Cli/Properties/launchSettings.json +++ b/src/Queil.Ring.DotNet.Cli/Properties/launchSettings.json @@ -1,8 +1,7 @@ { "profiles": { "Queil.Ring.DotNet.Cli": { - "commandName": "Project", - "commandLineArgs": "headless" + "commandName": "Project" } } -} \ No newline at end of file +} diff --git a/tests/Ring.Tests.Integration/McpClient.fs b/tests/Ring.Tests.Integration/McpClient.fs index f765578..3502c1c 100644 --- a/tests/Ring.Tests.Integration/McpClient.fs +++ b/tests/Ring.Tests.Integration/McpClient.fs @@ -2,8 +2,6 @@ namespace Ring.Tests.Integration open System open System.Diagnostics -open System.IO -open System.Text open System.Text.Json open System.Threading.Tasks open Ring.Tests.Integration.DotNet.Types @@ -20,26 +18,20 @@ module McpClient = msgId let sendMessage (msg: string) = - let bytes = Encoding.UTF8.GetBytes(msg) - let header = $"Content-Length: {bytes.Length}\r\n\r\n" - proc.StandardInput.Write(header) - proc.StandardInput.Write(msg) + proc.StandardInput.WriteLine(msg) proc.StandardInput.Flush() let readMessage () = task { - let mutable contentLength = 0 + let mutable result = "" let mutable line = proc.StandardOutput.ReadLine() - while line <> null && line <> "" do - if line.StartsWith("Content-Length:") then - contentLength <- int (line.Substring("Content-Length:".Length).Trim()) - line <- proc.StandardOutput.ReadLine() - if contentLength > 0 then - let buf = Array.zeroCreate contentLength - let! _ = proc.StandardOutput.ReadAsync(buf, 0, contentLength) |> Async.AwaitTask - return String(buf) - else - return "" + while line <> null && result = "" do + let trimmed = line.Trim() + if trimmed.StartsWith("{") then + result <- trimmed + else + line <- proc.StandardOutput.ReadLine() + return result } let callRpc (method: string) (paramsObj: obj) = @@ -60,8 +52,8 @@ module McpClient = member _.Start() = let name, cmdArgs = match options.LocalTool with - | None -> "ring", [ "mcp"; "--no-logo" ] - | Some _ -> "dotnet", [ "ring"; "mcp"; "--no-logo" ] + | None -> "ring", [ "run"; "--mcp"; "--no-logo" ] + | Some _ -> "dotnet", [ "ring"; "run"; "--mcp"; "--no-logo" ] let allArgs = cmdArgs From e44d9cb4c923bfcc1d4187189b349393d13b3a75 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:02:03 +0000 Subject: [PATCH 3/6] fix: MCP --- src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs | 16 +++------------- src/Queil.Ring.DotNet.Cli/Program.cs | 15 +++++++++++---- tests/Ring.Tests.Integration/McpClient.fs | 5 +++-- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs index 57ff421..0393dce 100644 --- a/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs +++ b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs @@ -3,22 +3,12 @@ namespace Queil.Ring.DotNet.Cli.Mcp; using System.Threading; using System.Threading.Tasks; using Infrastructure; -using Infrastructure.Cli; using Microsoft.Extensions.Hosting; -public class McpInitializer(IServer server, BaseOptions options, IHostApplicationLifetime lifetime) : IHostedService +public class McpInitializer(IServer server, IHostApplicationLifetime lifetime) : IHostedService { - public async Task StartAsync(CancellationToken cancellationToken) - { - var token = lifetime.ApplicationStopping; - await server.InitializeAsync(token); - - if (options is ConsoleOptions { WorkspacePath: { } path }) - { - await server.LoadAsync(path, token); - await server.StartAsync(token); - } - } + public async Task StartAsync(CancellationToken cancellationToken) => + await server.InitializeAsync(lifetime.ApplicationStopping); public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/Queil.Ring.DotNet.Cli/Program.cs b/src/Queil.Ring.DotNet.Cli/Program.cs index be6f475..92713fa 100644 --- a/src/Queil.Ring.DotNet.Cli/Program.cs +++ b/src/Queil.Ring.DotNet.Cli/Program.cs @@ -190,6 +190,17 @@ static string Ring(string ver) => app.UseWebSockets(); app.UseMiddleware(); + if (isMcp && options is ConsoleOptions { WorkspacePath: { } workspacePath }) + { + app.Lifetime.ApplicationStarted.Register(async () => + { + var server = app.Services.GetRequiredService(); + var ct = app.Lifetime.ApplicationStopping; + await server.LoadAsync(workspacePath, ct); + await server.StartAsync(ct); + }); + } + if (!isMcp) { app.Lifetime.ApplicationStarted.Register(async () => @@ -212,7 +223,3 @@ await app.Services.GetRequiredService().StartAsync(app.Lifetime.A Log.Logger.Fatal($"Unhandled exception: {ex}"); Environment.ExitCode = -1; } -finally -{ - Directory.SetCurrentDirectory(originalWorkingDir); -} diff --git a/tests/Ring.Tests.Integration/McpClient.fs b/tests/Ring.Tests.Integration/McpClient.fs index 3502c1c..f73a186 100644 --- a/tests/Ring.Tests.Integration/McpClient.fs +++ b/tests/Ring.Tests.Integration/McpClient.fs @@ -50,10 +50,11 @@ module McpClient = } member _.Start() = + let verb = if workspacePath.IsSome then "run" else "headless" let name, cmdArgs = match options.LocalTool with - | None -> "ring", [ "run"; "--mcp"; "--no-logo" ] - | Some _ -> "dotnet", [ "ring"; "run"; "--mcp"; "--no-logo" ] + | None -> "ring", [ verb; "--mcp"; "--port"; "0"; "--no-logo" ] + | Some _ -> "dotnet", [ "ring"; verb; "--mcp"; "--port"; "0"; "--no-logo" ] let allArgs = cmdArgs From 1fc4a8031a33e98570737a38242ae1b736c5cb72 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:13:15 +0000 Subject: [PATCH 4/6] tweaks --- .../Infrastructure/Queue.cs | 17 ++++++++++++----- src/Queil.Ring.DotNet.Cli/Program.cs | 5 ++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs index 3403f88..c2f3bf4 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs @@ -10,15 +10,18 @@ public sealed class Queue() : ISender, IReceiver, IDisposable { private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _channelCompleted = new(); + private int _completed; public void Complete() { - _channel.Writer.Complete(); - _channelCompleted.Cancel(); + if (Interlocked.Exchange(ref _completed, 1) == 0) + { + _channel.Writer.Complete(); + _channelCompleted.Cancel(); + } } - private readonly CancellationTokenSource _channelCompleted = new(); - public CancellationToken Completed => _channelCompleted.Token; public async Task WaitToReadAsync(CancellationToken token) @@ -64,5 +67,9 @@ private static byte[] CopyBytes(Message message) return bytes; } - public void Dispose() => _channelCompleted.Dispose(); + public void Dispose() + { + Complete(); + _channelCompleted.Dispose(); + } } diff --git a/src/Queil.Ring.DotNet.Cli/Program.cs b/src/Queil.Ring.DotNet.Cli/Program.cs index 92713fa..4615ef5 100644 --- a/src/Queil.Ring.DotNet.Cli/Program.cs +++ b/src/Queil.Ring.DotNet.Cli/Program.cs @@ -100,9 +100,8 @@ static string Ring(string ver) => { var configuredPath = f.GetRequiredService>().Value.Kubernetes.ConfigPath; var maybeKubeconfigEnv = Environment.GetEnvironmentVariable("KUBECONFIG"); - var configPath = maybeKubeconfigEnv ?? configuredPath; - if (!isMcp && configPath == null) throw new InvalidOperationException("Kubernetes config path is not set"); - if (configPath == null) return (Kubernetes)null!; + var configPath = maybeKubeconfigEnv ?? configuredPath + ?? throw new InvalidOperationException("Kubernetes config path is not set"); return new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath)); }); From ef50bb726900a364e9d4d4f49fa88ae6f962e836 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:25:09 +0000 Subject: [PATCH 5/6] tweaks --- src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs | 15 +++------------ src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs | 10 ++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs index c2f3bf4..55ff587 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs @@ -7,19 +7,15 @@ using System.Threading.Tasks; using Protocol; -public sealed class Queue() : ISender, IReceiver, IDisposable +public sealed class Queue() : ISender, IReceiver { private readonly Channel _channel = Channel.CreateUnbounded(); private readonly CancellationTokenSource _channelCompleted = new(); - private int _completed; public void Complete() { - if (Interlocked.Exchange(ref _completed, 1) == 0) - { - _channel.Writer.Complete(); - _channelCompleted.Cancel(); - } + _channel.Writer.Complete(); + _channelCompleted.Cancel(); } public CancellationToken Completed => _channelCompleted.Token; @@ -67,9 +63,4 @@ private static byte[] CopyBytes(Message message) return bytes; } - public void Dispose() - { - Complete(); - _channelCompleted.Dispose(); - } } diff --git a/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs b/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs index b0c47b1..f7b7901 100644 --- a/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs +++ b/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs @@ -68,6 +68,16 @@ public async Task ApplyFlavour(string flavour, CancellationToken ct) return ack == Ack.NotFound ? $"not found: {flavour}" : "applied"; } + [McpServerTool(Name = "list_tasks", Title = "List available tasks")] + public string ListTasks() + { + var sb = new StringBuilder(); + foreach (var r in launcher.GetCurrentInfo().Runnables) + foreach (var task in r.Tasks) + sb.AppendLine($"{r.Id}/{task}"); + return sb.Length > 0 ? sb.ToString() : "no tasks available"; + } + [McpServerTool(Name = "execute_task", Title = "Execute task")] public async Task ExecuteTask(string runnableId, string taskId, CancellationToken ct) { From 216d043226ea235644b18183285b75bd0ca2101e Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:27:44 +0000 Subject: [PATCH 6/6] chore: add tasks test --- tests/Ring.Tests.Integration/Tests.Mcp.fs | 15 +++++++++++++++ tests/resources/basic/proc-with-tasks.toml | 13 +++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/resources/basic/proc-with-tasks.toml diff --git a/tests/Ring.Tests.Integration/Tests.Mcp.fs b/tests/Ring.Tests.Integration/Tests.Mcp.fs index 8dc3830..fdcb8d7 100644 --- a/tests/Ring.Tests.Integration/Tests.Mcp.fs +++ b/tests/Ring.Tests.Integration/Tests.Mcp.fs @@ -14,6 +14,7 @@ let private expectedTools = "exclude_runnable" "get_workspace_info" "include_runnable" + "list_tasks" "load_workspace" "start_workspace" "stop_workspace" @@ -115,6 +116,20 @@ let tests = "proc-1 should reach ZERO state after exclusion" |> Expect.isTrue ok } + testTask "list_tasks returns tasks from loaded workspace" { + use ctx = new TestContext(localOptions) + let! (ring: Ring, dir: TestDir) = ctx.Init() + let workspace = dir.InSourceDir "../resources/basic/proc-with-tasks.toml" + let mcp = ring.McpProcess() + use _mcp = mcp + mcp.Start() + do! mcp.Initialize() + let! _ = mcp.CallTool("load_workspace", [ "workspacePath", workspace ]) + let! (result: string) = mcp.CallTool("list_tasks") + "Should list proc-1/greet task" |> Expect.isTrue (result.Contains("proc-1/greet")) + "Should list proc-2/greet task" |> Expect.isTrue (result.Contains("proc-2/greet")) + } + testTask "auto-loads and starts workspace when --workspace flag is provided" { use ctx = new TestContext(localOptions) let! (ring: Ring, dir: TestDir) = ctx.Init() diff --git a/tests/resources/basic/proc-with-tasks.toml b/tests/resources/basic/proc-with-tasks.toml new file mode 100644 index 0000000..fd3f23c --- /dev/null +++ b/tests/resources/basic/proc-with-tasks.toml @@ -0,0 +1,13 @@ +[tasks.proc.greet] + command = "echo" + args = ["hello"] + +[[proc]] +command = "sleep" +args = ["30"] +id = "proc-1" + +[[proc]] +command = "sleep" +args = ["40"] +id = "proc-2"