From fc4ef3b0f8a2663de8379f9a06a13fd1b1cb7489 Mon Sep 17 00:00:00 2001 From: hzhang Date: Thu, 1 May 2025 23:01:29 +0100 Subject: [PATCH] add: terminal tool --- Alchegos.MCP.csproj | 7 +- Alchegos.MCP.http | 6 - Dockerfile | 7 +- Program.cs | 52 ++- Properties/launchSettings.json | 23 -- Tools/GiteaTool.cs | 8 + Tools/TerminalTool.cs | 593 +++++++++++++++++++++++++++++++++ Tools/YoutrackTool.cs | 8 + appsettings.Development.json | 8 - appsettings.json | 9 - 10 files changed, 638 insertions(+), 83 deletions(-) delete mode 100644 Alchegos.MCP.http delete mode 100644 Properties/launchSettings.json create mode 100644 Tools/GiteaTool.cs create mode 100644 Tools/TerminalTool.cs create mode 100644 Tools/YoutrackTool.cs delete mode 100644 appsettings.Development.json delete mode 100644 appsettings.json diff --git a/Alchegos.MCP.csproj b/Alchegos.MCP.csproj index ff80447..058d402 100644 --- a/Alchegos.MCP.csproj +++ b/Alchegos.MCP.csproj @@ -1,6 +1,7 @@ + Exe net9.0 enable enable @@ -8,7 +9,11 @@ - + + + + + diff --git a/Alchegos.MCP.http b/Alchegos.MCP.http deleted file mode 100644 index 331ab80..0000000 --- a/Alchegos.MCP.http +++ /dev/null @@ -1,6 +0,0 @@ -@Alchegos.MCP_HostAddress = http://localhost:5031 - -GET {{Alchegos.MCP_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/Dockerfile b/Dockerfile index c6db72b..988a721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,15 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base -USER $APP_UID WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 + FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY ["*.sln", "."] COPY ["Alchegos.MCP.csproj", "./"] RUN dotnet restore "Alchegos.MCP.csproj" COPY . . -WORKDIR "/src/" +WORKDIR "/src/" RUN dotnet build "./Alchegos.MCP.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish diff --git a/Program.cs b/Program.cs index d5e0ef3..858a873 100644 --- a/Program.cs +++ b/Program.cs @@ -1,41 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Diagnostics; +using Alchegos.MCP.Tools; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithToolsFromAssembly(); +builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(5050)); var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); +app.MapMcp(); app.Run(); -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +[McpServerToolType] +public static class EchoTool { - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file + [McpServerTool, Description("Echoes the message back to the client.")] + public static string Echo(string message) => $"hello {message}"; +} + diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json deleted file mode 100644 index 397f00a..0000000 --- a/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5031", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7222;http://localhost:5031", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/Tools/GiteaTool.cs b/Tools/GiteaTool.cs new file mode 100644 index 0000000..a6528b7 --- /dev/null +++ b/Tools/GiteaTool.cs @@ -0,0 +1,8 @@ +using ModelContextProtocol.Server; + +namespace Alchegos.MCP.Tools; +[McpServerToolType] +public static class GiteaTool +{ + +} \ No newline at end of file diff --git a/Tools/TerminalTool.cs b/Tools/TerminalTool.cs new file mode 100644 index 0000000..62d0f4b --- /dev/null +++ b/Tools/TerminalTool.cs @@ -0,0 +1,593 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using ModelContextProtocol.Server; + +namespace Alchegos.MCP.Tools; + +[McpServerToolType, Description("Manage multiple persistent terminal sessions, enabling creation, closure, command execution, and output retrieval.")] +public static class TerminalTool +{ + private static readonly ConcurrentDictionary _sessions = new(); + + public record SessionInfo( + [property: Description("Unique identifier for the session.")] string SessionId, + [property: Description("Human-readable label for the session.")] string Label + ); + + public record ListSessionsResult( + [property: Description("List of all active sessions with their IDs and labels.")] List Sessions + ); + + public record CreateSessionResult( + [property: Description("Status of the operation: 'success' or 'fail'.")] string Status, + [property: Description("Details of the created session if successful; otherwise, with empty fields.")] SessionInfo Session + ); + + public record ExitSessionResult( + [property: Description("Status of the operation: 'success' or 'fail'.")] string Status + ); + + public record SendSignalResult( + [property: Description("Status of the operation: 'success' or 'fail'.")] string Status + ); + + public record SendInputWithExceptResult( + [property: Description("Indicates how the response was triggered: 'except' when a regex matched, or 'timeout' when the timeout was reached.")] string OutputBy, + [property: Description("The regex pattern that matched, or null if none matched before timeout.")] string? MatchedExcept, + [property: Description("Accumulated standard output from the session after sending input.")] string Stdout, + [property: Description("Accumulated standard error from the session after sending input.")] string Stderr + ); + + public record SendInputAndForgetResult( + [property: Description("Status of the operation: 'success' or 'fail'.")] string Status + ); + + public record OneOffCommandResult( + [property: Description("How the command completed: 'finish' when the process exited normally, or 'timeout' when the timeout was reached.")] string ReturnBy, + [property: Description("Standard output produced by the command.")] string Stdout, + [property: Description("Standard error produced by the command.")] string Stderr + ); + + public record GetCachedResultResult( + [property: Description("Cached standard output of the session.")] string Stdout, + [property: Description("Cached standard error of the session.")] string Stderr + ); + + public record ClearResult( + [property: Description("Status of the operation: 'success' or 'fail'.")] string Status + ); + + private class TerminalSession : IDisposable + { + public string Id { get; } + public string Label { get; } + public Process Process { get; } + + private readonly StringBuilder _stdoutBuffer = new(); + private readonly StringBuilder _stderrBuffer = new(); + private readonly object _lockObject = new(); + + private bool _isDisposed; + + public TerminalSession(string id, string label) + { + Id = id; + Label = label; + + Process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = GetShellPath(), + Arguments = GetShellArguments(), + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }, + EnableRaisingEvents = true + }; + + Process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + lock (_lockObject) + { + _stdoutBuffer.AppendLine(e.Data); + } + } + }; + + Process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + lock (_lockObject) + { + _stderrBuffer.AppendLine(e.Data); + } + } + }; + + Process.Start(); + Process.BeginOutputReadLine(); + Process.BeginErrorReadLine(); + } + + private static string GetShellPath() + { + if (OperatingSystem.IsWindows()) + return "cmd.exe"; + return "/bin/bash"; + } + + private static string GetShellArguments() + { + if (OperatingSystem.IsWindows()) + return "/q"; + return "-i"; + } + + public void SendInput(string input) + { + if (!Process.HasExited) + { + Process.StandardInput.WriteLine(input); + Process.StandardInput.Flush(); + } + } + + public bool SendSignal(int signal) + { + try + { + if (OperatingSystem.IsWindows()) + { + if (signal == 2) + return NativeMethods.SendCtrlC(Process); + return false; + } + return NativeMethods.SendSignal(Process.Id, signal); + } + catch + { + return false; + } + } + + public (string stdout, string stderr) GetAndClearBuffers() + { + lock (_lockObject) + { + string stdout = _stdoutBuffer.ToString(); + string stderr = _stderrBuffer.ToString(); + + _stdoutBuffer.Clear(); + _stderrBuffer.Clear(); + + return (stdout, stderr); + } + } + + public (string stdout, string stderr) GetBuffers() + { + lock (_lockObject) + { + return (_stdoutBuffer.ToString(), _stderrBuffer.ToString()); + } + } + + public void ClearBuffers() + { + lock (_lockObject) + { + _stdoutBuffer.Clear(); + _stderrBuffer.Clear(); + } + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + try + { + if (!Process.HasExited) + { + Process.StandardInput.WriteLine("exit"); + Process.StandardInput.Flush(); + + if (!Process.WaitForExit(1000)) + Process.Kill(); + } + } + catch + { + // Best effort only + } + finally + { + Process.Dispose(); + } + } + } + + private static class NativeMethods + { + public static bool SendCtrlC(Process process) + { + try + { + if (process.HasExited) + return false; + + if (!AttachConsole((uint)process.Id)) + return false; + + SetConsoleCtrlHandler(null, true); + try + { + if (!GenerateConsoleCtrlEvent(CtrlTypes.CTRL_C_EVENT, 0)) + return false; + return true; + } + finally + { + SetConsoleCtrlHandler(null, false); + FreeConsole(); + } + } + catch + { + return false; + } + } + + public static bool SendSignal(int pid, int signal) + { + try + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + var killProcess = Process.Start(new ProcessStartInfo + { + FileName = "kill", + Arguments = $"-{signal} {pid}", + UseShellExecute = false, + CreateNoWindow = true + }); + + killProcess?.WaitForExit(); + return killProcess?.ExitCode == 0; + } + + return false; + } + catch + { + return false; + } + } + + // Windows Console API imports + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + public static extern bool AttachConsole(uint dwProcessId); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + public static extern bool FreeConsole(); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + public static extern bool SetConsoleCtrlHandler(ConsoleCtrlHandler? HandlerRoutine, bool Add); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + public static extern bool GenerateConsoleCtrlEvent(CtrlTypes dwCtrlEvent, uint dwProcessGroupId); + + public enum CtrlTypes + { + CTRL_C_EVENT = 0, + CTRL_BREAK_EVENT = 1, + CTRL_CLOSE_EVENT = 2, + CTRL_LOGOFF_EVENT = 5, + CTRL_SHUTDOWN_EVENT = 6 + } + + public delegate bool ConsoleCtrlHandler(CtrlTypes ctrlType); + } + + //——— Tool Methods ———// + + [McpServerTool, Description(""" + List all active terminal sessions. + Return Format: + { + sessions: [ { session_id: string, label: string }, ... ] + } + """)] + public static ListSessionsResult ListSessions() + { + var sessions = _sessions.Values.Select(s => new SessionInfo(s.Id, s.Label)).ToList(); + return new ListSessionsResult(sessions); + } + + [McpServerTool, Description(""" + Create a new terminal session. + Return Format: + { + status: 'success' | 'fail', + session: { session_id: string, label: string } + } + """)] + public static CreateSessionResult CreateSession( + [Description("Memorable label for the new session.")] string label + ) + { + try + { + string sessionId = Guid.NewGuid().ToString("N"); + var session = new TerminalSession(sessionId, label); + + if (_sessions.TryAdd(sessionId, session)) + { + return new CreateSessionResult("success", new SessionInfo(sessionId, label)); + } + + session.Dispose(); + return new CreateSessionResult("fail", new SessionInfo(string.Empty, string.Empty)); + } + catch (Exception) + { + return new CreateSessionResult("fail", new SessionInfo(string.Empty, string.Empty)); + } + } + + [McpServerTool, Description(""" + Exit a terminal session. Fails if the session ID does not exist. + Return Format: + { + status: 'success' | 'fail' + } + """)] + public static ExitSessionResult ExitSession( + [Description("ID of the session to exit.")] string sessionId + ) + { + if (_sessions.TryRemove(sessionId, out var session)) + { + session.Dispose(); + return new ExitSessionResult("success"); + } + + return new ExitSessionResult("fail"); + } + + [McpServerTool, Description(""" + Send a signal to a terminal session (e.g., SIGINT=2). + Return Format: + { + status: 'success' | 'fail' + } + """)] + public static SendSignalResult SendSignal( + [Description("Session ID to send the signal to.")] string sessionId, + [Description("Signal number to send (e.g., 2 for SIGINT)." )] int signal + ) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + bool success = session.SendSignal(signal); + return new SendSignalResult(success ? "success" : "fail"); + } + + return new SendSignalResult("fail"); + } + + [McpServerTool, Description(""" + Send input to a terminal session and wait until the output matches one of the exception regexes or timeout. + Use cases: interactive prompts (e.g., confirmation prompts, password input). + Return Format: + { + output_by: 'except' | 'timeout', + matched_except: string | null, + stdout: string, + stderr: string + } + Note: cached stdout/stderr is cleared after this call. + """)] + public static SendInputWithExceptResult SendInputWithExcept( + [Description("Session ID to send input to.")] string sessionId, + [Description("Input text to send (without newline)." )] string input, + [Description("Timeout in milliseconds.")] int timeout, + [Description("Regex patterns to match against output.")] params string[] excepts + ) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + { + return new SendInputWithExceptResult("timeout", null, string.Empty, string.Empty); + } + + session.SendInput(input); + + var regexPatterns = excepts.Select(pattern => new Regex(pattern, RegexOptions.Compiled)).ToArray(); + + string matchedPattern = null; + bool matched = false; + var watch = Stopwatch.StartNew(); + + while (watch.ElapsedMilliseconds < timeout && !matched) + { + var (stdout, stderr) = session.GetBuffers(); + string combinedOutput = stdout + stderr; + + for (int i = 0; i < regexPatterns.Length; i++) + { + if (regexPatterns[i].IsMatch(combinedOutput)) + { + matchedPattern = excepts[i]; + matched = true; + break; + } + } + + if (!matched) + { + Thread.Sleep(10); + } + } + + watch.Stop(); + + var (finalStdout, finalStderr) = session.GetAndClearBuffers(); + + return new SendInputWithExceptResult( + matched ? "except" : "timeout", + matchedPattern, + finalStdout, + finalStderr + ); + } + + [McpServerTool, Description(""" + Send input to a terminal session without waiting for output. Output is cached for later retrieval. + Use cases: long-running or background commands. e.g. ssh + Return Format: + { + status: 'success' | 'fail' + } + """)] + public static SendInputAndForgetResult SendInputAndForget( + [Description("Session ID to send input to.")] string sessionId, + [Description("Input text to send (without newline)." )] string input + ) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + try + { + session.SendInput(input); + return new SendInputAndForgetResult("success"); + } + catch + { + return new SendInputAndForgetResult("fail"); + } + } + + return new SendInputAndForgetResult("fail"); + } + + [McpServerTool, Description(""" + Execute a one-off command in the specified session and return stdout/stderr when done or timed out. + Use cases: quick commands that complete shortly (e.g., ls, echo). + Return Format: + { + return_by: 'finish' | 'timeout', + stdout: string, + stderr: string + } + """)] + public static OneOffCommandResult OneOffCommand( + [Description("Session ID to execute the command in.")] string sessionId, + [Description("Command to execute, including arguments.")] string command, + [Description("Timeout in milliseconds.")] int timeout + ) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + { + return new OneOffCommandResult("timeout", string.Empty, string.Empty); + } + + session.ClearBuffers(); + + string marker = $"CMD_COMPLETE_{Guid.NewGuid():N}"; + + if (OperatingSystem.IsWindows()) + { + session.SendInput($"{command} & echo {marker}"); + } + else + { + session.SendInput($"{command}; echo {marker}"); + } + + bool completed = false; + var watch = Stopwatch.StartNew(); + + while (watch.ElapsedMilliseconds < timeout && !completed) + { + var (stdout, _) = session.GetBuffers(); + + if (stdout.Contains(marker)) + { + completed = true; + } + else + { + Thread.Sleep(10); + } + } + + watch.Stop(); + + var (finalStdout, finalStderr) = session.GetAndClearBuffers(); + + if (completed) + { + finalStdout = Regex.Replace(finalStdout, $"{marker}\r?\n?$", ""); + } + + return new OneOffCommandResult( + completed ? "finish" : "timeout", + finalStdout, + finalStderr + ); + } + + [McpServerTool, Description(""" + Get and clear cached stdout/stderr of the specified session. + Return Format: + { + stdout: string, + stderr: string + } + """)] + public static GetCachedResultResult GetCachedResult( + [Description("Session ID to retrieve output from.")] string sessionId + ) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + var (stdout, stderr) = session.GetAndClearBuffers(); + return new GetCachedResultResult(stdout, stderr); + } + + return new GetCachedResultResult(string.Empty, string.Empty); + } + + [McpServerTool, Description(""" + Clear cached stdout/stderr of the specified session. + Return Format: + { + status: 'success' | 'fail' + } + """)] + public static ClearResult Clear( + [Description("Session ID to clear cache for.")] string sessionId + ) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + session.ClearBuffers(); + return new ClearResult("success"); + } + + return new ClearResult("fail"); + } +} diff --git a/Tools/YoutrackTool.cs b/Tools/YoutrackTool.cs new file mode 100644 index 0000000..d428c5a --- /dev/null +++ b/Tools/YoutrackTool.cs @@ -0,0 +1,8 @@ +using ModelContextProtocol.Server; + +namespace Alchegos.MCP.Tools; +[McpServerToolType] +public static class YoutrackTool +{ + +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/appsettings.json b/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -}