Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Queil.Ring.DotNet.Cli/Infrastructure/Cli/BaseOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/Queil.Ring.DotNet.Cli/Infrastructure/Queue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,17 @@
using System.Threading.Tasks;
using Protocol;

public sealed class Queue() : ISender, IReceiver, IDisposable
public sealed class Queue() : ISender, IReceiver
{
private readonly Channel<byte[]> _channel = Channel.CreateUnbounded<byte[]>();
private readonly CancellationTokenSource _channelCompleted = new();

public void Complete()
{
_channel.Writer.Complete();
_channelCompleted.Cancel();
}

private readonly CancellationTokenSource _channelCompleted = new();

public CancellationToken Completed => _channelCompleted.Token;

public async Task<bool> WaitToReadAsync(CancellationToken token)
Expand Down Expand Up @@ -64,5 +63,4 @@ private static byte[] CopyBytes(Message message)
return bytes;
}

public void Dispose() => _channelCompleted.Dispose();
}
3 changes: 3 additions & 0 deletions src/Queil.Ring.DotNet.Cli/Infrastructure/Server.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
{
Expand Down
14 changes: 14 additions & 0 deletions src/Queil.Ring.DotNet.Cli/Mcp/McpInitializer.cs
Original file line number Diff line number Diff line change
@@ -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;
}
92 changes: 92 additions & 0 deletions src/Queil.Ring.DotNet.Cli/Mcp/RingMcpTools.cs
Original file line number Diff line number Diff line change
@@ -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<string> LoadWorkspace(string workspacePath, CancellationToken ct)
{
await server.LoadAsync(workspacePath, ct);
return "loaded";
}

[McpServerTool(Name = "start_workspace", Title = "Start workspace")]
public async Task<string> StartWorkspace(CancellationToken ct)
{
await server.StartAsync(ct);
return "started";
}

[McpServerTool(Name = "stop_workspace", Title = "Stop workspace")]
public async Task<string> StopWorkspace(CancellationToken ct)
{
await server.StopAsync(ct);
return "stopped";
}

[McpServerTool(Name = "unload_workspace", Title = "Unload workspace")]
public async Task<string> UnloadWorkspace(CancellationToken ct)
{
await server.UnloadAsync(ct);
return "unloaded";
}

[McpServerTool(Name = "include_runnable", Title = "Include runnable")]
public async Task<string> 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<string> 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<string> 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<string> 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"
};
}
}
72 changes: 54 additions & 18 deletions src/Queil.Ring.DotNet.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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()));
Expand All @@ -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<BaseOptions>());
if (options is ServeOptions so) services.AddSingleton(so);
services.AddSingleton<Func<Uri, HttpClient>>(_ =>
uri => clients.GetOrAdd(uri, new HttpClient { BaseAddress = uri, MaxResponseContentBufferSize = 1 }));
services.AddOptions();
Expand All @@ -88,13 +100,24 @@ static string Ring(string ver) =>
{
var configuredPath = f.GetRequiredService<IOptions<RingConfiguration>>().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<WebsocketsInitializer>();
services.AddSingleton<ConsoleClient>();

if (isMcp)
{
services.AddHostedService<McpInitializer>();
services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RingMcpTools>();
}
else
{
services.AddSingleton<ConsoleClient>();
}

services.AddTransient<ProcessRunner>();
services.AddTransient<KustomizeTool>();
Expand Down Expand Up @@ -166,9 +189,23 @@ static string Ring(string ver) =>
app.UseWebSockets();
app.UseMiddleware<RingMiddleware>();

app.Lifetime.ApplicationStarted.Register(async () =>
await app.Services.GetRequiredService<ConsoleClient>().StartAsync(app.Lifetime.ApplicationStopping)
);
if (isMcp && options is ConsoleOptions { WorkspacePath: { } workspacePath })
{
app.Lifetime.ApplicationStarted.Register(async () =>
{
var server = app.Services.GetRequiredService<IServer>();
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<ConsoleClient>().StartAsync(app.Lifetime.ApplicationStopping)
);
}

await app.RunRingAsync();
}
Expand All @@ -177,12 +214,11 @@ await app.Services.GetRequiredService<ConsoleClient>().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);
}
5 changes: 2 additions & 3 deletions src/Queil.Ring.DotNet.Cli/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
{
"profiles": {
"Queil.Ring.DotNet.Cli": {
"commandName": "Project",
"commandLineArgs": "headless"
"commandName": "Project"
}
}
}
}
2 changes: 2 additions & 0 deletions src/Queil.Ring.DotNet.Cli/Queil.Ring.DotNet.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
<PackageReference Include="CommandLineParser" Version="2.*"/>
<PackageReference Include="KubernetesClient" Version="18.*"/>
<PackageReference Include="LightInject.Microsoft.AspNetCore.Hosting" Version="2.*"/>
<PackageReference Include="ModelContextProtocol" Version="1.0.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="9.*"/>
<PackageReference Include="Serilog.Sinks.File" Version="6.*"/>
<PackageReference Include="Stateless" Version="5.*"/>
<PackageReference Include="Tomlyn.Extensions.Configuration" Version="1.*"/>
</ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions src/Queil.Ring.DotNet.Cli/Workspace/IWorkspaceLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public interface IWorkspaceLauncher
Task<IncludeResult> IncludeAsync(string id, CancellationToken token);
Task<ApplyFlavourResult> ApplyFlavourAsync(string flavour, CancellationToken token);
void PublishStatus(ServerState serverState);
WorkspaceInfo GetCurrentInfo();
Task<ExecuteTaskResult> ExecuteTaskAsync(RunnableTask task, CancellationToken token);
event EventHandler OnInitiated;
}
7 changes: 3 additions & 4 deletions src/Queil.Ring.DotNet.Cli/Workspace/WorkspaceLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,9 @@ public async Task<IncludeResult> 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)
{
Expand Down
Loading