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 > 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;
}
}
|