节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
12.94 KiB
Stardust
using System.Text.Json;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using Xunit;
using Xunit.Abstractions;

namespace Stardust.McpApiTests;

/// <summary>34 工具全覆盖测试:从 tools/list 拿全部工具清单,逐工具正路径调用并断言成功。
/// 使用初始化数据(mcpapi-* 前缀,属于「MCP 测试」项目 Id=4)。写工具使用低副作用参数。</summary>
[Collection(nameof(McpApiTestCollection))]
public class McpToolCoverageTests : McpSdkTestBase
{
    public McpToolCoverageTests(ITestOutputHelper output) : base(output) { }

    /// <summary>tools/list 应返回全部 34 个工具(与代码库 IMcpTool 实现数量一致,G5 口径)</summary>
    [Fact(DisplayName = "覆盖-1:tools/list 返回全部 34 个工具")]
    public async Task ListTools_Count34()
    {
        await RunSdkAsync(McpTestDataInitializer.Tokens["mcpapi-full"], async client =>
        {
            var tools = await client.ListToolsAsync();
            var names = tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
            Output.WriteLine($"✅ tools/list 共 {tools.Count} 个工具:{String.Join(", ", names)}");
            Assert.Equal(34, tools.Count);
        });
    }

    /// <summary>每个工具都有 InputSchema(SDK 层面工具契约完整)</summary>
    [Fact(DisplayName = "覆盖-2:每个工具 InputSchema 非空")]
    public async Task ListTools_AllHaveSchema()
    {
        await RunSdkAsync(McpTestDataInitializer.Tokens["mcpapi-full"], async client =>
        {
            var tools = await client.ListToolsAsync();
            var bad = tools.Where(t => t.JsonSchema.ValueKind == JsonValueKind.Undefined).ToList();
            Assert.True(bad.Count == 0, $"以下工具缺少 InputSchema:{String.Join(", ", bad.Select(t => t.Name))}");
            Output.WriteLine($"✅ {tools.Count} 个工具均有 InputSchema");
        });
    }

    /// <summary>逐工具正路径调用。读工具用初始化数据 ID;写工具用低副作用参数(dry_run / 安全命令 / confirm=true)。
    /// Agent 依赖工具(node_command/app_command/app_process/deploy_node_operate/deploy_log_*)在无真实节点 Agent 的测试环境会
    /// -32000「工具执行失败:目标不可达」或优雅空数据,属环境限制而非网关缺陷;对它们断言调用被网关正确受理(不抛 -32001/-32003/-32602)。
    /// deploy_log_* 对未配置日志目录的部署节点快速失败(配置合并处拦截,不下发指令),返回空数据+note。</summary>
    [Fact(DisplayName = "覆盖-3:34 个工具逐个正路径调用")]
    public async Task CallAllTools_PositivePath()
    {
        var token = McpTestDataInitializer.Tokens["mcpapi-full"];
        var appId = McpTestDataInitializer.AppId;
        var configId = McpTestDataInitializer.ConfigId;
        var deployId = McpTestDataInitializer.DeployId;
        var versionId = McpTestDataInitializer.VersionId;
        var deployNodeId = McpTestDataInitializer.DeployNodeId;
        var pipelineId = McpTestDataInitializer.PipelineId;
        var runId = McpTestDataInitializer.PipelineRunId;
        var alarmId = McpTestDataInitializer.AlarmId;
        var historyId = McpTestDataInitializer.HistoryId;
        var configHistoryId = McpTestDataInitializer.ConfigHistoryId;
        var traceDataId = McpTestDataInitializer.TraceDataId;

        // 依赖真实节点 Agent(6600) 的工具:真实集成测试下(本机 6600+StarAgent 在线)应真实执行成功;
        // 无真实 Agent 环境时只能验证网关受理(允许 -32000 目标不可达),并单独记录。
        var agentDependent = new HashSet<String>(StringComparer.Ordinal)
        {
            "stardust_node_command", "stardust_app_command", "stardust_app_process", "stardust_deploy_node_operate",
        };

        // 真实节点 Agent 在线时使用真实节点/真实命令;否则回退静态测试数据(网关受理断言)
        var realNode = McpTestDataInitializer.RealNodeId > 0;
        var nodeId = realNode ? McpTestDataInitializer.RealNodeId : McpTestDataInitializer.NodeId;

        // 每个工具名 → 正路径参数(依据各工具 InputSchema / 实现读取逻辑)
        var calls = new Dictionary<String, Dictionary<String, Object?>>
        {
            ["stardust_token_whoami"] = new(),
            ["stardust_project_list"] = new(),
            ["stardust_audit_list"] = new(),
            ["stardust_now_changes"] = new(),
            ["stardust_app_list"] = new(),
            ["stardust_app_health"] = new() { ["app_id"] = appId },
            ["stardust_app_topology"] = new() { ["app_id"] = appId },
            ["stardust_app_logs"] = new() { ["app_id"] = appId },
            ["stardust_service_lookup"] = new() { ["service_name"] = "mcpapi" },
            ["stardust_node_list"] = new(),
            ["stardust_node_detail"] = new() { ["node_id"] = nodeId },
            ["stardust_node_apps"] = new() { ["node_id"] = nodeId },
            ["stardust_node_command"] = new() { ["node_id"] = nodeId, ["command"] = "cmd", ["args"] = "echo mcp-api-test", ["confirm"] = true },
            ["stardust_app_command"] = new() { ["app_id"] = appId, ["command"] = "app/freeMemory", ["confirm"] = true },
            ["stardust_app_process"] = new() { ["app_id"] = appId, ["action"] = "start", ["confirm"] = true },
            ["stardust_deploy_status"] = new() { ["deploy_id"] = deployId },
            ["stardust_deploy_versions"] = new() { ["deploy_id"] = deployId },
            ["stardust_deploy_history"] = new() { ["deploy_id"] = deployId },
            ["stardust_pipeline_run_detail"] = new() { ["run_id"] = runId },
            ["stardust_deploy_use_version"] = new() { ["version_id"] = versionId },
            ["stardust_deploy_node_operate"] = new() { ["deploy_node_id"] = deployNodeId, ["action"] = "start", ["confirm"] = true },
            ["stardust_deploy_log_files"] = new() { ["deploy_node_id"] = deployNodeId },
            ["stardust_deploy_log_tail"] = new() { ["deploy_node_id"] = deployNodeId, ["file"] = "app.log" },
            ["stardust_deploy_release"] = new() { ["deploy_id"] = deployId, ["version"] = "1.0.0", ["dry_run"] = true },
            ["stardust_pipeline_trigger"] = new() { ["pipeline_id"] = pipelineId },
            ["stardust_config_list"] = new() { ["app_id"] = appId },
            ["stardust_config_get"] = new() { ["config_id"] = configId },
            ["stardust_config_set"] = new() { ["config_id"] = configId, ["configs"] = new Dictionary<String, Object?> { ["McpApiTestKey"] = "value" } },
            ["stardust_config_rollback"] = new() { ["history_id"] = configHistoryId },
            ["stardust_alarm_list"] = new(),
            ["stardust_alarm_detail"] = new() { ["alarm_id"] = alarmId },
            ["stardust_alarm_resolve"] = new() { ["alarm_id"] = alarmId, ["note"] = "mcp-api-test" },
            ["stardust_trace_search"] = new() { ["app_id"] = appId },
            ["stardust_trace_detail"] = new() { ["data_id"] = traceDataId },
        };
        Assert.Equal(34, calls.Count);

        var failed = new List<String>();
        var success = new List<String>();
        var agentLimited = new List<String>();
        var agentReal = new List<String>();
        await RunSdkAsync(token, async client =>
        {
            foreach (var (name, args) in calls)
            {
                try
                {
                    var result = await client.CallToolAsync(name, args);
                    var content = AssertOk(result);

                    if (agentDependent.Contains(name))
                    {
                        // 真实集成断言:真实节点/实例在线时要求命令真实执行成功(回包非空 / command_id>0);
                        // 无真实 Agent 环境时仅记录网关受理(业务执行受限),不判失败。
                        if (realNode)
                        {
                            var real = VerifyRealExecution(name, content);

                            // deploy_node_operate 是 fire-and-forget(timeout=0):command_id>0 只证明命令落库与鉴权链,
                            // 再轮询 NodeCommand 状态离开「就绪」,确认命令已真实推达节点并收到 Agent 回包
                            if (real && name == "stardust_deploy_node_operate")
                                real = await WaitNodeCommandRepliedAsync(content, Output);

                            if (real)
                            {
                                agentReal.Add(name);
                                Output.WriteLine($"  ✅ {name} 真实节点执行成功:{content.GetProperty("data")}");
                            }
                            else
                            {
                                agentLimited.Add(name);
                                Output.WriteLine($"  ⚠ {name} 网关受理但未获真实回包:{content.GetProperty("data")}");
                            }
                        }
                        else
                        {
                            agentLimited.Add(name);
                            Output.WriteLine($"  ⚠ {name} 网关受理(无真实节点 Agent):{content.GetProperty("data")}");
                        }
                    }
                    else
                    {
                        success.Add(name);
                        Output.WriteLine($"  ✅ {name} 调用成功");
                    }
                }
                catch (McpProtocolException mpe) when (agentDependent.Contains(name) && (Int32)mpe.ErrorCode == -32000 && !realNode)
                {
                    // 无真实节点 Agent 环境:网关已受理(未抛 -32001/-32003/-32602),业务执行 -32000 目标不可达,属环境限制
                    agentLimited.Add(name);
                    Output.WriteLine($"  ⚠ {name} 网关受理,业务执行 -32000(无真实节点 Agent):{mpe.Message}");
                }
                catch (Exception ex)
                {
                    failed.Add($"{name}: {ex.GetType().Name} {ex.Message}");
                }
            }
        });

        Assert.True(failed.Count == 0,
            $"以下工具正路径失败({failed.Count} 个):{Environment.NewLine}{String.Join(Environment.NewLine, failed)}");
        Output.WriteLine($"✅ {success.Count} 个工具正路径调用成功;{agentReal.Count} 个 Agent 依赖工具真实节点执行成功:{String.Join(", ", agentReal)};{agentLimited.Count} 个网关受理(业务执行受限):{String.Join(", ", agentLimited)}");
    }

    /// <summary>真实执行成功判定:node_command/app_command/app_process 要求 data.reply 非空且 status 非空;
    /// deploy_node_operate 要求 data.command_id &gt; 0(fire-and-forget 命令已真实落库并推达节点)。</summary>
    private static Boolean VerifyRealExecution(String name, JsonElement content)
    {
        if (!content.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return false;
        if (name == "stardust_deploy_node_operate")
        {
            if (!data.TryGetProperty("command_id", out var cid)) return false;
            return cid.ValueKind == JsonValueKind.Number && cid.GetInt64() > 0;
        }
        if (!data.TryGetProperty("reply", out var reply) || reply.ValueKind != JsonValueKind.Object) return false;
        if (!reply.TryGetProperty("status", out var st)) return false;
        var s = st.ValueKind == JsonValueKind.String ? st.GetString() : st.ToString();
        if (String.IsNullOrEmpty(s) || s == "null") return false;
        // 服务端等不到客户端回包时会返回兜底包 {status=就绪, data=null},必须要求 data 非空才算真实回包
        if (!reply.TryGetProperty("data", out var rd)) return false;
        return rd.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined);
    }

    /// <summary>轮询 NodeCommand 状态离开「就绪」,确认 deploy 命令已真实推达节点并收到 Agent 回包。
    /// Agent 在线时:命令被推送后状态变「处理中」,回包后落终态(成功/错误/取消);离线时停留「就绪」。最多等 15 秒。</summary>
    private static async Task<Boolean> WaitNodeCommandRepliedAsync(JsonElement content, ITestOutputHelper output)
    {
        Int32 cid = 0;
        if (content.TryGetProperty("data", out var data) && data.TryGetProperty("command_id", out var c) && c.ValueKind == JsonValueKind.Number)
            cid = c.GetInt32();
        if (cid <= 0) return false;

        for (var i = 0; i < 15; i++)
        {
            var cmd = Stardust.Data.Nodes.NodeCommand.Find(Stardust.Data.Nodes.NodeCommand._.Id == cid);
            if (cmd != null && cmd.Status != NewLife.Remoting.Models.CommandStatus.就绪)
            {
                output.WriteLine($"    ↳ NodeCommand[{cid}] 状态={cmd.Status} 结果={cmd.Result}");
                return true;
            }
            await Task.Delay(1000);
        }
        return false;
    }
}