using System.Text.Json; using ModelContextProtocol.Client; using RackPeek.Domain.Api; using RackPeek.Domain.Resources.Connections; using RackPeek.Domain.Resources.Hardware; using RackPeek.Mcp.Tools; namespace Tests.Mcp; /// /// The read half of the tool surface, over the seed inventory in /// . Assertions state exact expectations — the seed is /// small enough that anything looser would just be hiding a wrong answer. /// public class QueryToolTests { // -- list_resources ----------------------------------------------------------------- [Fact] public async Task Listing_returns_every_resource_with_its_key_facts() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ResourceList list = await client.CallOkAsync("list_resources"); Assert.Equal(5, list.Count); ResourceRow hostOs = Assert.Single(list.Resources, r => r.Name == "host-os"); Assert.Equal("System", hostOs.Kind); Assert.Equal("10.0.0.5", hostOs.Ip); Assert.Equal(["rack-server"], hostOs.RunsOn); Assert.Equal("prod", Assert.Contains("env", hostOs.Labels)); } [Theory] [InlineData("Server", "rack-server")] [InlineData("server", "rack-server")] // kind matching must not be case-sensitive [InlineData("Switch", "rack-switch")] [InlineData("System", "host-os")] public async Task Listing_filters_by_kind(string kind, string expected) { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ResourceList list = await client.CallOkAsync( "list_resources", new Dictionary { ["kind"] = kind }); ResourceRow row = Assert.Single(list.Resources); Assert.Equal(expected, row.Name); } [Fact] public async Task Listing_filters_by_tag_and_label_key() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ResourceList byTag = await client.CallOkAsync( "list_resources", new Dictionary { ["tag"] = "prod" }); ResourceList byLabel = await client.CallOkAsync( "list_resources", new Dictionary { ["labelKey"] = "env" }); Assert.Equal("rack-server", Assert.Single(byTag.Resources).Name); Assert.Equal("host-os", Assert.Single(byLabel.Resources).Name); } [Fact] public async Task An_unknown_kind_is_an_empty_list_not_an_error() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ResourceList list = await client.CallOkAsync( "list_resources", new Dictionary { ["kind"] = "mainframe" }); Assert.Equal(0, list.Count); } // -- get_resource ------------------------------------------------------------------- [Fact] public async Task A_resource_comes_back_as_schema_conformant_yaml_with_its_connections() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ResourceDetail detail = await client.CallOkAsync( "get_resource", new Dictionary { ["name"] = "rack-server" }); Assert.Contains("kind: Server", detail.Yaml); Assert.Contains("name: rack-server", detail.Yaml); Assert.Contains("rack-switch", detail.Yaml); // the connection's far end Assert.Equal(["host-os"], detail.Dependants); SchemaAssert.ConformsToSchema(detail.Yaml); } [Fact] public async Task The_yaml_from_get_resource_round_trips_through_upsert_without_phantom_changes() { // The read and write halves must agree on the format, or an agent that reads, // tweaks nothing and writes back would report changes it never made. using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ResourceDetail detail = await client.CallOkAsync( "get_resource", new Dictionary { ["name"] = "host-os" }); ImportYamlResponse response = await client.CallOkAsync( "upsert_resources", new Dictionary { ["yaml"] = detail.Yaml, ["dryRun"] = true }); Assert.Empty(response.Added); Assert.Empty(response.Updated); Assert.Empty(response.Replaced); } [Fact] public async Task Asking_for_a_resource_that_does_not_exist_is_a_useful_error() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var error = await client.CallErrorAsync( "get_resource", new Dictionary { ["name"] = "no-such-box" }); Assert.Contains("no-such-box", error); Assert.Contains("not found", error); } // -- search_resources --------------------------------------------------------------- [Fact] public async Task Search_finds_by_name_ip_tag_and_label() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); SearchResults byName = await client.CallOkAsync( "search_resources", new Dictionary { ["query"] = "grafana" }); SearchResults byIp = await client.CallOkAsync( "search_resources", new Dictionary { ["query"] = "10.0.1.9" }); Assert.Equal("grafana", byName.Matches.First().Name); Assert.Equal("prometheus", byIp.Matches.First().Name); } [Fact] public async Task Search_respects_the_max_argument() { using var api = new McpFixture(TestData.DemoConfig()); await using McpClient client = await api.ConnectAsync(); SearchResults matches = await client.CallOkAsync( "search_resources", new Dictionary { ["query"] = "e", ["max"] = 3 }); Assert.Equal(3, matches.Matches.Count); } // -- get_summary --------------------------------------------------------------------- [Fact] public async Task The_summary_counts_everything_in_one_call() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); InfrastructureSummary summary = await client.CallOkAsync("get_summary"); Assert.Equal(2, summary.Hardware.TotalHardware); Assert.Equal(1, summary.Systems.TotalSystems); Assert.Equal(2, summary.Services.TotalServices); Assert.Equal(1, Assert.Contains("prod", summary.Tags)); Assert.Equal(1, Assert.Contains("env", summary.Labels)); } // -- get_tree ------------------------------------------------------------------------ [Fact] public async Task The_tree_nests_services_under_systems_under_hardware() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); TreeResult tree = await client.CallOkAsync("get_tree"); HardwareTree server = Assert.Single(tree.Hardware, h => h.HardwareName == "rack-server"); SystemTree system = Assert.Single(server.Systems); Assert.Equal("host-os", system.SystemName); Assert.Equal(["grafana", "prometheus"], system.Services.OrderBy(s => s, StringComparer.Ordinal)); } [Fact] public async Task The_tree_can_be_narrowed_to_one_hardware_resource() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); TreeResult tree = await client.CallOkAsync( "get_tree", new Dictionary { ["hardwareName"] = "rack-switch" }); Assert.Equal("rack-switch", Assert.Single(tree.Hardware).HardwareName); var error = await client.CallErrorAsync( "get_tree", new Dictionary { ["hardwareName"] = "no-such-rack" }); Assert.Contains("not found", error); } // -- list_connections ---------------------------------------------------------------- [Fact] public async Task Connections_are_listed_whole_and_per_resource() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ConnectionList all = await client.CallOkAsync("list_connections"); ConnectionList forSwitch = await client.CallOkAsync( "list_connections", new Dictionary { ["resource"] = "rack-switch" }); ConnectionList forHost = await client.CallOkAsync( "list_connections", new Dictionary { ["resource"] = "host-os" }); Connection connection = Assert.Single(all.Connections); Assert.Equal("rack-server", connection.A.Resource); Assert.Equal("rack-switch", connection.B.Resource); Assert.Equal("uplink", connection.Label); Assert.Equal(1, forSwitch.Count); Assert.Equal(0, forHost.Count); } // -- get_subnets --------------------------------------------------------------------- [Fact] public async Task Service_ips_group_into_subnets_and_filter_by_cidr() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); JsonElement grouped = await client.CallOkAsync("get_subnets"); JsonElement filtered = await client.CallOkAsync( "get_subnets", new Dictionary { ["cidr"] = "10.0.0.0/24" }); var subnets = grouped.GetProperty("subnets").EnumerateArray() .Select(s => s.GetProperty("cidr").GetString()) .ToList(); Assert.Equal(["10.0.0.0/24", "10.0.1.0/24"], subnets); var services = filtered.GetProperty("services").EnumerateArray() .Select(s => s.GetProperty("name").GetString()) .ToList(); Assert.Equal(["grafana"], services); } [Fact] public async Task A_malformed_cidr_is_an_error_that_shows_the_right_shape() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var error = await client.CallErrorAsync( "get_subnets", new Dictionary { ["cidr"] = "not-a-cidr" }); Assert.Contains("not-a-cidr", error); Assert.Contains("192.168.1.0/24", error); } // -- get_schema ---------------------------------------------------------------------- [Fact] public async Task The_schema_tool_serves_the_current_published_schema() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); SchemaInfo info = await client.CallOkAsync("get_schema"); Assert.Equal(4, info.Version); Assert.False(string.IsNullOrWhiteSpace(info.Guidance)); // Byte-for-byte the schema the repo publishes, so the tool cannot drift. var published = await File.ReadAllTextAsync( Path.Combine(AppContext.BaseDirectory, "schemas", "schema.v4.json")); Assert.Equal(published, info.JsonSchema); } // -- breadth over the demo inventory --------------------------------------------------- [Fact] public async Task The_whole_demo_inventory_lists_trees_and_reads_back_conformant_yaml() { using var api = new McpFixture(TestData.DemoConfig()); await using McpClient client = await api.ConnectAsync(); ResourceList list = await client.CallOkAsync("list_resources"); Assert.Equal(45, list.Count); TreeResult tree = await client.CallOkAsync("get_tree"); Assert.NotEmpty(tree.Hardware); foreach (var name in new[] { "proxmox-node01", "pfsense-fw", "plex", "proxmox-cluster-node01" }) { ResourceDetail detail = await client.CallOkAsync( "get_resource", new Dictionary { ["name"] = name }); SchemaAssert.ConformsToSchema(detail.Yaml); } } }