Commit 00b0f40f authored by shrabbit's avatar shrabbit
Browse files

feat(RequestTools): 新增可观察控制台工具,启动交互式程序并通过命名管道回传输出供 Agent 读取

parent ff04e0a8
Loading
Loading
Loading
Loading
+61 −0
Original line number Diff line number Diff line
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;

namespace McpBase.McpServer;

/// <summary>
/// 一个由 <see cref="Tools.RequestTools.CreateWatchableConsole"/> 创建的可观察控制台条目,
/// 包含宿主 Request 进程、回传输出流的命名管道服务端,以及服务端侧持续累积的输出缓冲。
/// </summary>
public sealed class WatchableConsole
{
    /// <summary>宿主 Request.exe 进程(其内部会启动用户指定的自定义程序)。</summary>
    public required Process RequestProcess { get; init; }

    /// <summary>用于接收 Request.exe 转发的子程序输出的命名管道服务端流(方向:In)。</summary>
    public required NamedPipeServerStream Pipe { get; init; }

    /// <summary>服务端侧持续累积的输出缓冲;后台任务从管道读取并写入此处。访问需加锁。</summary>
    public required StringBuilder Buffer { get; init; }

    /// <summary>后台读取管道并写入 <see cref="Buffer"/> 的任务;管道关闭(EOF)后结束。</summary>
    public required Task ReaderTask { get; init; }
}

public class ProcessMap : Dictionary<string, WatchableConsole>
{
    /// <summary>
    /// 加入一个可观察控制台条目,返回随机生成的唯一标识符。
    /// </summary>
    /// <param name="wc">可观察控制台条目。</param>
    /// <returns>观察 ID。</returns>
    public string Add(WatchableConsole wc)
    {
        var str = string.Empty;

        do
        {
            str = GenerateRandomHexString();
        } while (ContainsKey(str));

        Add(str, wc);

        return str;
    }

    /// <summary>
    /// 生成 8 位随机十六进制字符串。
    /// </summary>
    public string GenerateRandomHexString()
    {
        var arr = new char[8];
        var hex = "0123456789abcdef";
        for (var i = 0; i < arr.Length; i++)
        {
            arr[i] = hex[Random.Shared.Next(0, hex.Length)];
        }

        return new string(arr);
    }
}
+2 −0
Original line number Diff line number Diff line
using System.Reflection;
using McpBase.McpServer;
using McpBase.McpServer.Tools;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -13,6 +14,7 @@ builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
    .AddSingleton<IRestClient, RestClient>()
    .AddSingleton<ProcessMap>()
    .AddMcpServer(c =>
    {
        
+93 −3
Original line number Diff line number Diff line
using System.ComponentModel;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using McpBase.Shared;
using ModelContextProtocol.Server;

namespace McpBase.McpServer.Tools;

public class RequestTools
public class RequestTools(ProcessMap pm)
{
    [McpServerTool, Description("Pause execution and wait indefinitely for human interaction. A separate window (McpBase.Request.exe) is launched showing the provided message to the user; the call blocks until the user closes that window. Use this when the agent needs human confirmation, review, or intervention before continuing. Returns the elapsed wait time in seconds (string, 3 decimal places). Note: the launched window is independent and does not share stdin/stdout with the MCP server.")]
    public async Task<string> InfiniteWait([Description("Message displayed to the user in the waiting window. Explain clearly what the agent is waiting for and what action the user should take to resume (e.g. 'Please review the proposed change and close this window when ready to continue'). Supports Spectre.Console markup syntax for colored/styled output; use explicit closing tags for clarity (e.g. [red]error text[/red], [bold]important[/bold], [green]success[/green], [dim]secondary[/dim], [yellow]warning[/yellow]). Styles can be combined (e.g. [bold red]critical[/bold red]). To output a literal '[' or ']' character, double it as '[[' or ']]'.")] string? msg)
@@ -33,4 +35,92 @@ public class RequestTools

        return sw.Elapsed.TotalSeconds.ToString("f3");
    }

    [McpServerTool, Description("创建一个可观察的控制台:在独立窗口中由 McpBase.Request.exe 启动指定的交互式命令行程序(如 cmd/pwsh/bash),并将其输出流通过命名管道回传,供 Agent 通过 ReadWatchableConsole 观察。返回观察 ID(8 位十六进制)。注意:由于未使用 PTY,部分行编辑/TUI 程序可能表现受限;用户在 Request 窗口中输入命令、查看输出,Agent 同步观察同一输出流。")]
    public string CreateWatchableConsole(
        [Description("可执行程序路径,支持后跟参数。如果位于环境变量(PATH)中,则仅需文件名即可。仅支持交互式命令行界面(如 bash, cmd, pwsh...),不支持普通命令行与 GUI 程序。")] string fileName,
        [Description("在 Request 窗口中显示的提示文本,支持 Spectre.Console 标记语法。")] string? message = "")
    {
        // 服务端侧创建命名管道(方向 In:接收 Request 转发的子程序输出)。
        var pipeName = $"mcpbase.watch.{Guid.NewGuid():N}";
        var pipe = new NamedPipeServerStream(
            pipeName,
            PipeDirection.In,
            maxNumberOfServerInstances: 1,
            PipeTransmissionMode.Byte,
            PipeOptions.Asynchronous);

        // 后台:等待 Request 连接 → 持续读取并累积到 Buffer。
        var buffer = new StringBuilder();
        var readerTask = Task.Run(async () =>
        {
            try
            {
                using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
                await pipe.WaitForConnectionAsync(cts.Token);
                using var sr = new StreamReader(pipe);
                var buf = new char[4096];
                int n;
                while ((n = await sr.ReadAsync(buf, 0, buf.Length)) > 0)
                {
                    lock (buffer)
                        buffer.Append(buf, 0, n);
                }
            }
            catch
            {
                // 管道异常(超时、连接断开等)不影响主流程;缓冲中保留已读取内容。
            }
        });

        var json = JsonSerializer.Serialize(new SendWatchableTransport
        {
            Message = message ?? string.Empty,
            Cmd = fileName,
            PipeName = pipeName,
        });

        var psi = new ProcessStartInfo(Path.Join(AppDomain.CurrentDomain.BaseDirectory, "McpBase.Request.exe"))
        {
            ArgumentList =
            {
                json
            },
            UseShellExecute = true
        };

        var process = Process.Start(psi);
        if (process is null)
        {
            try { pipe.Dispose(); } catch { }
            throw new InvalidOperationException("无法启动 McpBase.Request.exe。");
        }

        var id = pm.Add(new WatchableConsole
        {
            RequestProcess = process,
            Pipe = pipe,
            Buffer = buffer,
            ReaderTask = readerTask
        });

        return id;
    }

    [McpServerTool, Description("读取指定可观察控制台的输出流内容(自上次读取以来累积的快照)。")]
    public string ReadWatchableConsole(
        [Description("CreateWatchableConsole 返回的观察 ID。")] string id,
        [Description("是否在读取后清空已读缓冲(默认 true)。设为 false 可重复读取历史输出。")] bool consume = true)
    {
        if (!pm.TryGetValue(id, out var wc))
            return $"错误:找不到观察 ID '{id}'。";

        lock (wc.Buffer)
        {
            var snapshot = wc.Buffer.ToString();
            if (consume && snapshot.Length > 0)
                wc.Buffer.Clear();
            return snapshot;
        }
    }
}
+177 −4
Original line number Diff line number Diff line
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using McpBase.Shared;
using Spectre.Console;
@@ -20,7 +23,7 @@ class Program
            {
                AnsiConsole.MarkupLine(message);
            }
            catch (Exception e)
            catch (Exception)
            {
                // 当标记语法错误时,降级普通输出。
                Console.WriteLine(message);
@@ -29,6 +32,8 @@ class Program
            {
            }
        });

        HandleTid<SendWatchableTransport>(tp!.Tid, args[0], 2, RunWatchable);
    }

    private static void HandleTid<T>(int input, string json, int id, Action<T> action)
@@ -37,4 +42,172 @@ class Program

        action(JsonSerializer.Deserialize<T>(json)!);
    }

    /// <summary>
    /// 启动用户指定的自定义交互式程序,并将其输出同时转发到本控制台(用户可见)
    /// 与命名管道(Agent 观察);stdin 由本控制台转发给程序。
    /// </summary>
    private static void RunWatchable(SendWatchableTransport s)
    {
        if (!string.IsNullOrEmpty(s.Message))
        {
            try { AnsiConsole.MarkupLine(s.Message); }
            catch { Console.WriteLine(s.Message); }
        }
        AnsiConsole.MarkupLine($"[dim]命令:{s.Cmd.EscapeMarkup()}[/]");
        AnsiConsole.MarkupLine("[dim](程序退出后本窗口将自动关闭)[/]");
        AnsiConsole.WriteLine();

        var (file, args) = SplitCommandLine(s.Cmd);
        if (string.IsNullOrWhiteSpace(file))
        {
            AnsiConsole.MarkupLine("[red]未提供可执行程序。[/]");
            WaitKey();
            return;
        }

        var psi = new ProcessStartInfo(file)
        {
            UseShellExecute = false,
            RedirectStandardInput = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true,
            StandardOutputEncoding = Console.OutputEncoding,
            StandardErrorEncoding = Console.OutputEncoding,
        };
        if (!string.IsNullOrEmpty(args))
            psi.Arguments = args;

        // 连接观察管道(方向 Out:向服务端写入输出)。
        NamedPipeClientStream? pipe = null;
        StreamWriter? pipeWriter = null;
        try
        {
            pipe = new NamedPipeClientStream(".", s.PipeName, PipeDirection.Out, PipeOptions.Asynchronous);
            pipe.Connect(5000);
            pipeWriter = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
        }
        catch (Exception e)
        {
            // 管道不可用时仍可向本控制台输出,仅 Agent 观察不可用。
            try { AnsiConsole.MarkupLine($"[yellow]警告:无法连接观察管道,Agent 将无法读取输出。({e.Message.EscapeMarkup()})[/]"); }
            catch { Console.WriteLine($"警告:无法连接观察管道。({e.Message})"); }
        }

        Process child;
        try
        {
            child = new Process { StartInfo = psi };
            if (!child.Start())
                throw new InvalidOperationException("Process.Start 返回 false。");
        }
        catch (Exception e)
        {
            try { AnsiConsole.MarkupLine($"[red]无法启动指定程序:{e.Message.EscapeMarkup()}[/]"); }
            catch { Console.WriteLine($"无法启动指定程序:{e.Message}"); }
            pipeWriter?.Dispose();
            pipe?.Dispose();
            WaitKey();
            return;
        }

        // 子程序 stdout → 控制台 + 管道
        var stdoutTask = Task.Run(() => Relay(child.StandardOutput, Console.Out, pipeWriter));
        // 子程序 stderr → 控制台错误 + 管道
        var stderrTask = Task.Run(() => Relay(child.StandardError, Console.Error, pipeWriter));
        // 控制台 stdin → 子程序 stdin(fire-and-forget:子程序退出后随进程结束而终止)
        Task.Run(() =>
        {
            try
            {
                string? line;
                while ((line = Console.In.ReadLine()) != null)
                {
                    try
                    {
                        child.StandardInput.WriteLine(line);
                        child.StandardInput.Flush();
                    }
                    catch
                    {
                        break; // 子程序已关闭 stdin
                    }
                }
            }
            catch { }
            try { child.StandardInput.Close(); } catch { }
        });

        child.WaitForExit();
        // 等待输出排空(EOF 后 Read 返回 0,任务结束);带超时以防孙子进程持有 stdout。
        // 注意:此处不能再调用 Console.ReadKey 等,因为上面的 stdin 转发线程正阻塞在控制台读取上。
        try { Task.WaitAll(new[] { stdoutTask, stderrTask }, 2000); } catch { }
        try { child.StandardInput.Close(); } catch { }

        // 关闭管道(让服务端读到 EOF)。本进程随即退出,窗口自动关闭。
        try { pipeWriter?.Flush(); } catch { }
        pipeWriter?.Dispose();
        pipe?.Dispose();
    }

    /// <summary>
    /// 将源流的输出同时写入控制台与(可选)观察管道。
    /// </summary>
    private static void Relay(StreamReader source, TextWriter console, StreamWriter? pipe)
    {
        try
        {
            var buf = new char[256];
            int n;
            while ((n = source.Read(buf, 0, buf.Length)) > 0)
            {
                console.Write(buf, 0, n);
                console.Flush();
                try
                {
                    pipe?.Write(buf, 0, n);
                    pipe?.Flush();
                }
                catch { }
            }
        }
        catch { }
    }

    /// <summary>
    /// 将命令行拆分为「可执行程序路径」与「参数字符串」。
    /// 支持首个 token 用双引号包裹(含空格的路径);参数字符串原样透传给子进程。
    /// </summary>
    private static (string file, string args) SplitCommandLine(string? cmd)
    {
        if (string.IsNullOrWhiteSpace(cmd))
            return ("", "");

        cmd = cmd.Trim();
        if (cmd[0] == '"')
        {
            var end = cmd.IndexOf('"', 1);
            if (end < 0)
                return (cmd.Substring(1), "");
            var file = cmd.Substring(1, end - 1);
            var rest = cmd.Substring(end + 1).TrimStart();
            return (file, rest);
        }

        var sp = cmd.IndexOf(' ');
        if (sp < 0)
            return (cmd, "");
        return (cmd.Substring(0, sp), cmd.Substring(sp + 1).TrimStart());
    }

    private static void WaitKey()
    {
        try
        {
            AnsiConsole.MarkupLine("[dim](按任意键关闭此窗口)[/]");
            Console.ReadKey(true);
        }
        catch { }
    }
}
+12 −0
Original line number Diff line number Diff line
namespace McpBase.Shared;

public class SendWatchableTransport : TransportWithMessage<SendWatchableTransport>
{
    public override int Tid { get; set; } = 2;
    public string Cmd { get; set; } = string.Empty;

    /// <summary>
    /// 服务端创建的命名管道名称,Request 端作为客户端连接并向其写入子程序输出。
    /// </summary>
    public string PipeName { get; set; } = string.Empty;
}
 No newline at end of file
Loading