QueryToolTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. using System.Text.Json;
  2. using ModelContextProtocol.Client;
  3. using RackPeek.Domain.Api;
  4. using RackPeek.Domain.Resources.Connections;
  5. using RackPeek.Domain.Resources.Hardware;
  6. using RackPeek.Mcp.Tools;
  7. namespace Tests.Mcp;
  8. /// <summary>
  9. /// The read half of the tool surface, over the seed inventory in
  10. /// <see cref="TestData.Seed" />. Assertions state exact expectations — the seed is
  11. /// small enough that anything looser would just be hiding a wrong answer.
  12. /// </summary>
  13. public class QueryToolTests {
  14. // -- list_resources -----------------------------------------------------------------
  15. [Fact]
  16. public async Task Listing_returns_every_resource_with_its_key_facts() {
  17. using var api = new McpFixture(TestData.Seed);
  18. await using McpClient client = await api.ConnectAsync();
  19. ResourceList list = await client.CallOkAsync<ResourceList>("list_resources");
  20. Assert.Equal(5, list.Count);
  21. ResourceRow hostOs = Assert.Single(list.Resources, r => r.Name == "host-os");
  22. Assert.Equal("System", hostOs.Kind);
  23. Assert.Equal("10.0.0.5", hostOs.Ip);
  24. Assert.Equal(["rack-server"], hostOs.RunsOn);
  25. Assert.Equal("prod", Assert.Contains("env", hostOs.Labels));
  26. }
  27. [Theory]
  28. [InlineData("Server", "rack-server")]
  29. [InlineData("server", "rack-server")] // kind matching must not be case-sensitive
  30. [InlineData("Switch", "rack-switch")]
  31. [InlineData("System", "host-os")]
  32. public async Task Listing_filters_by_kind(string kind, string expected) {
  33. using var api = new McpFixture(TestData.Seed);
  34. await using McpClient client = await api.ConnectAsync();
  35. ResourceList list = await client.CallOkAsync<ResourceList>(
  36. "list_resources", new Dictionary<string, object?> { ["kind"] = kind });
  37. ResourceRow row = Assert.Single(list.Resources);
  38. Assert.Equal(expected, row.Name);
  39. }
  40. [Fact]
  41. public async Task Listing_filters_by_tag_and_label_key() {
  42. using var api = new McpFixture(TestData.Seed);
  43. await using McpClient client = await api.ConnectAsync();
  44. ResourceList byTag = await client.CallOkAsync<ResourceList>(
  45. "list_resources", new Dictionary<string, object?> { ["tag"] = "prod" });
  46. ResourceList byLabel = await client.CallOkAsync<ResourceList>(
  47. "list_resources", new Dictionary<string, object?> { ["labelKey"] = "env" });
  48. Assert.Equal("rack-server", Assert.Single(byTag.Resources).Name);
  49. Assert.Equal("host-os", Assert.Single(byLabel.Resources).Name);
  50. }
  51. [Fact]
  52. public async Task An_unknown_kind_is_an_empty_list_not_an_error() {
  53. using var api = new McpFixture(TestData.Seed);
  54. await using McpClient client = await api.ConnectAsync();
  55. ResourceList list = await client.CallOkAsync<ResourceList>(
  56. "list_resources", new Dictionary<string, object?> { ["kind"] = "mainframe" });
  57. Assert.Equal(0, list.Count);
  58. }
  59. // -- get_resource -------------------------------------------------------------------
  60. [Fact]
  61. public async Task A_resource_comes_back_as_schema_conformant_yaml_with_its_connections() {
  62. using var api = new McpFixture(TestData.Seed);
  63. await using McpClient client = await api.ConnectAsync();
  64. ResourceDetail detail = await client.CallOkAsync<ResourceDetail>(
  65. "get_resource", new Dictionary<string, object?> { ["name"] = "rack-server" });
  66. Assert.Contains("kind: Server", detail.Yaml);
  67. Assert.Contains("name: rack-server", detail.Yaml);
  68. Assert.Contains("rack-switch", detail.Yaml); // the connection's far end
  69. Assert.Equal(["host-os"], detail.Dependants);
  70. SchemaAssert.ConformsToSchema(detail.Yaml);
  71. }
  72. [Fact]
  73. public async Task The_yaml_from_get_resource_round_trips_through_upsert_without_phantom_changes() {
  74. // The read and write halves must agree on the format, or an agent that reads,
  75. // tweaks nothing and writes back would report changes it never made.
  76. using var api = new McpFixture(TestData.Seed);
  77. await using McpClient client = await api.ConnectAsync();
  78. ResourceDetail detail = await client.CallOkAsync<ResourceDetail>(
  79. "get_resource", new Dictionary<string, object?> { ["name"] = "host-os" });
  80. ImportYamlResponse response = await client.CallOkAsync<ImportYamlResponse>(
  81. "upsert_resources", new Dictionary<string, object?> { ["yaml"] = detail.Yaml, ["dryRun"] = true });
  82. Assert.Empty(response.Added);
  83. Assert.Empty(response.Updated);
  84. Assert.Empty(response.Replaced);
  85. }
  86. [Fact]
  87. public async Task Asking_for_a_resource_that_does_not_exist_is_a_useful_error() {
  88. using var api = new McpFixture(TestData.Seed);
  89. await using McpClient client = await api.ConnectAsync();
  90. var error = await client.CallErrorAsync(
  91. "get_resource", new Dictionary<string, object?> { ["name"] = "no-such-box" });
  92. Assert.Contains("no-such-box", error);
  93. Assert.Contains("not found", error);
  94. }
  95. // -- search_resources ---------------------------------------------------------------
  96. [Fact]
  97. public async Task Search_finds_by_name_ip_tag_and_label() {
  98. using var api = new McpFixture(TestData.Seed);
  99. await using McpClient client = await api.ConnectAsync();
  100. SearchResults byName = await client.CallOkAsync<SearchResults>(
  101. "search_resources", new Dictionary<string, object?> { ["query"] = "grafana" });
  102. SearchResults byIp = await client.CallOkAsync<SearchResults>(
  103. "search_resources", new Dictionary<string, object?> { ["query"] = "10.0.1.9" });
  104. Assert.Equal("grafana", byName.Matches.First().Name);
  105. Assert.Equal("prometheus", byIp.Matches.First().Name);
  106. }
  107. [Fact]
  108. public async Task Search_respects_the_max_argument() {
  109. using var api = new McpFixture(TestData.DemoConfig());
  110. await using McpClient client = await api.ConnectAsync();
  111. SearchResults matches = await client.CallOkAsync<SearchResults>(
  112. "search_resources", new Dictionary<string, object?> { ["query"] = "e", ["max"] = 3 });
  113. Assert.Equal(3, matches.Matches.Count);
  114. }
  115. // -- get_summary ---------------------------------------------------------------------
  116. [Fact]
  117. public async Task The_summary_counts_everything_in_one_call() {
  118. using var api = new McpFixture(TestData.Seed);
  119. await using McpClient client = await api.ConnectAsync();
  120. InfrastructureSummary summary = await client.CallOkAsync<InfrastructureSummary>("get_summary");
  121. Assert.Equal(2, summary.Hardware.TotalHardware);
  122. Assert.Equal(1, summary.Systems.TotalSystems);
  123. Assert.Equal(2, summary.Services.TotalServices);
  124. Assert.Equal(1, Assert.Contains("prod", summary.Tags));
  125. Assert.Equal(1, Assert.Contains("env", summary.Labels));
  126. }
  127. // -- get_tree ------------------------------------------------------------------------
  128. [Fact]
  129. public async Task The_tree_nests_services_under_systems_under_hardware() {
  130. using var api = new McpFixture(TestData.Seed);
  131. await using McpClient client = await api.ConnectAsync();
  132. TreeResult tree = await client.CallOkAsync<TreeResult>("get_tree");
  133. HardwareTree server = Assert.Single(tree.Hardware, h => h.HardwareName == "rack-server");
  134. SystemTree system = Assert.Single(server.Systems);
  135. Assert.Equal("host-os", system.SystemName);
  136. Assert.Equal(["grafana", "prometheus"], system.Services.OrderBy(s => s, StringComparer.Ordinal));
  137. }
  138. [Fact]
  139. public async Task The_tree_can_be_narrowed_to_one_hardware_resource() {
  140. using var api = new McpFixture(TestData.Seed);
  141. await using McpClient client = await api.ConnectAsync();
  142. TreeResult tree = await client.CallOkAsync<TreeResult>(
  143. "get_tree", new Dictionary<string, object?> { ["hardwareName"] = "rack-switch" });
  144. Assert.Equal("rack-switch", Assert.Single(tree.Hardware).HardwareName);
  145. var error = await client.CallErrorAsync(
  146. "get_tree", new Dictionary<string, object?> { ["hardwareName"] = "no-such-rack" });
  147. Assert.Contains("not found", error);
  148. }
  149. // -- list_connections ----------------------------------------------------------------
  150. [Fact]
  151. public async Task Connections_are_listed_whole_and_per_resource() {
  152. using var api = new McpFixture(TestData.Seed);
  153. await using McpClient client = await api.ConnectAsync();
  154. ConnectionList all = await client.CallOkAsync<ConnectionList>("list_connections");
  155. ConnectionList forSwitch = await client.CallOkAsync<ConnectionList>(
  156. "list_connections", new Dictionary<string, object?> { ["resource"] = "rack-switch" });
  157. ConnectionList forHost = await client.CallOkAsync<ConnectionList>(
  158. "list_connections", new Dictionary<string, object?> { ["resource"] = "host-os" });
  159. Connection connection = Assert.Single(all.Connections);
  160. Assert.Equal("rack-server", connection.A.Resource);
  161. Assert.Equal("rack-switch", connection.B.Resource);
  162. Assert.Equal("uplink", connection.Label);
  163. Assert.Equal(1, forSwitch.Count);
  164. Assert.Equal(0, forHost.Count);
  165. }
  166. // -- get_subnets ---------------------------------------------------------------------
  167. [Fact]
  168. public async Task Service_ips_group_into_subnets_and_filter_by_cidr() {
  169. using var api = new McpFixture(TestData.Seed);
  170. await using McpClient client = await api.ConnectAsync();
  171. JsonElement grouped = await client.CallOkAsync<JsonElement>("get_subnets");
  172. JsonElement filtered = await client.CallOkAsync<JsonElement>(
  173. "get_subnets", new Dictionary<string, object?> { ["cidr"] = "10.0.0.0/24" });
  174. var subnets = grouped.GetProperty("subnets").EnumerateArray()
  175. .Select(s => s.GetProperty("cidr").GetString())
  176. .ToList();
  177. Assert.Equal(["10.0.0.0/24", "10.0.1.0/24"], subnets);
  178. var services = filtered.GetProperty("services").EnumerateArray()
  179. .Select(s => s.GetProperty("name").GetString())
  180. .ToList();
  181. Assert.Equal(["grafana"], services);
  182. }
  183. [Fact]
  184. public async Task A_malformed_cidr_is_an_error_that_shows_the_right_shape() {
  185. using var api = new McpFixture(TestData.Seed);
  186. await using McpClient client = await api.ConnectAsync();
  187. var error = await client.CallErrorAsync(
  188. "get_subnets", new Dictionary<string, object?> { ["cidr"] = "not-a-cidr" });
  189. Assert.Contains("not-a-cidr", error);
  190. Assert.Contains("192.168.1.0/24", error);
  191. }
  192. // -- get_schema ----------------------------------------------------------------------
  193. [Fact]
  194. public async Task The_schema_tool_serves_the_current_published_schema() {
  195. using var api = new McpFixture(TestData.Seed);
  196. await using McpClient client = await api.ConnectAsync();
  197. SchemaInfo info = await client.CallOkAsync<SchemaInfo>("get_schema");
  198. Assert.Equal(4, info.Version);
  199. Assert.False(string.IsNullOrWhiteSpace(info.Guidance));
  200. // Byte-for-byte the schema the repo publishes, so the tool cannot drift.
  201. var published = await File.ReadAllTextAsync(
  202. Path.Combine(AppContext.BaseDirectory, "schemas", "schema.v4.json"));
  203. Assert.Equal(published, info.JsonSchema);
  204. }
  205. // -- breadth over the demo inventory ---------------------------------------------------
  206. [Fact]
  207. public async Task The_whole_demo_inventory_lists_trees_and_reads_back_conformant_yaml() {
  208. using var api = new McpFixture(TestData.DemoConfig());
  209. await using McpClient client = await api.ConnectAsync();
  210. ResourceList list = await client.CallOkAsync<ResourceList>("list_resources");
  211. Assert.Equal(45, list.Count);
  212. TreeResult tree = await client.CallOkAsync<TreeResult>("get_tree");
  213. Assert.NotEmpty(tree.Hardware);
  214. foreach (var name in new[] { "proxmox-node01", "pfsense-fw", "plex", "proxmox-cluster-node01" }) {
  215. ResourceDetail detail = await client.CallOkAsync<ResourceDetail>(
  216. "get_resource", new Dictionary<string, object?> { ["name"] = name });
  217. SchemaAssert.ConformsToSchema(detail.Yaml);
  218. }
  219. }
  220. }