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/Queue.cs b/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs index 3403f88..55ff587 100644 --- a/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs +++ b/src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs @@ -7,9 +7,10 @@ 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(); public void Complete() { @@ -17,8 +18,6 @@ public void Complete() _channelCompleted.Cancel(); } - private readonly CancellationTokenSource _channelCompleted = new(); - public CancellationToken Completed => _channelCompleted.Token; public async Task WaitToReadAsync(CancellationToken token) @@ -64,5 +63,4 @@ private static byte[] CopyBytes(Message message) return bytes; } - public void Dispose() => _channelCompleted.Dispose(); } 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 new file mode 100644 index 0000000..0393dce --- /dev/null +++ b/src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs @@ -0,0 +1,14 @@ +namespace Queil.Ring.DotNet.Cli.Mcp; + +using System.Threading; +using System.Threading.Tasks; +using Infrastructure; +using Microsoft.Extensions.Hosting; + +public class McpInitializer(IServer server, IHostApplicationLifetime lifetime) : IHostedService +{ + 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/Mcp/RingMcpTools.cs b/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs new file mode 100644 index 0000000..f7b7901 --- /dev/null +++ b/src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs @@ -0,0 +1,92 @@ +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 = "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) + { + 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..4615ef5 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; @@ -21,6 +22,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,14 +39,24 @@ static string Ring(string ver) => try { - ThreadPool.SetMinThreads(100, 100); - Log.Logger = new LoggerConfiguration().WriteTo.Console() - .MinimumLevel.Information() - .MinimumLevel.Override("Microsoft", LogEventLevel.Error).CreateLogger(); - var options = CliParser.GetOptions(args, originalWorkingDir); + var isMcp = options is ServeOptions { Mcp: true }; + + if (isMcp) Console.SetOut(TextWriter.Null); - if (!options.NoLogo) + ThreadPool.SetMinThreads(100, 100); + 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())); @@ -67,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(); @@ -88,13 +100,24 @@ 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 + ?? throw new InvalidOperationException("Kubernetes config path is not set"); 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(); @@ -166,9 +189,23 @@ static string Ring(string ver) => app.UseWebSockets(); app.UseMiddleware(); - app.Lifetime.ApplicationStarted.Register(async () => - await app.Services.GetRequiredService().StartAsync(app.Lifetime.ApplicationStopping) - ); + 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 () => + await app.Services.GetRequiredService().StartAsync(app.Lifetime.ApplicationStopping) + ); + } await app.RunRingAsync(); } @@ -177,12 +214,11 @@ 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}"); Environment.ExitCode = -1; } -finally -{ - Directory.SetCurrentDirectory(originalWorkingDir); -} 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/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..f73a186 --- /dev/null +++ b/tests/Ring.Tests.Integration/McpClient.fs @@ -0,0 +1,141 @@ +namespace Ring.Tests.Integration + +open System +open System.Diagnostics +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) = + proc.StandardInput.WriteLine(msg) + proc.StandardInput.Flush() + + let readMessage () = + task { + let mutable result = "" + let mutable line = proc.StandardOutput.ReadLine() + 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) = + 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 verb = if workspacePath.IsSome then "run" else "headless" + let name, cmdArgs = + match options.LocalTool with + | None -> "ring", [ verb; "--mcp"; "--port"; "0"; "--no-logo" ] + | Some _ -> "dotnet", [ "ring"; verb; "--mcp"; "--port"; "0"; "--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..fdcb8d7 --- /dev/null +++ b/tests/Ring.Tests.Integration/Tests.Mcp.fs @@ -0,0 +1,150 @@ +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" + "list_tasks" + "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 "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() + 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" 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"