using ModelContextProtocol.Client; using RackPeek.Domain.Api; using RackPeek.Domain.Resources.Connections; using RackPeek.Mcp.Tools; namespace Tests.Mcp; /// /// The write half of the tool surface. Every assertion here is made against what /// actually lands in config.yaml — the file is the product, not the tool response. /// public class MutationToolTests { private const string _newServerYaml = """ version: 4 resources: - kind: Server name: new-server ram: size: 64 ports: - type: rj45 speed: 1 count: 2 """; // -- upsert_resources ---------------------------------------------------------------- [Fact] public async Task Upserting_a_new_resource_persists_it_as_schema_conformant_yaml() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ImportYamlResponse response = await client.CallOkAsync( "upsert_resources", new Dictionary { ["yaml"] = _newServerYaml }); Assert.Equal(["new-server"], response.Added); Assert.Contains("new-server", api.StoredYaml); SchemaAssert.ConformsToSchema(api.StoredYaml); } [Fact] public async Task A_dry_run_reports_the_diff_but_writes_nothing() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var before = api.StoredYaml; ImportYamlResponse response = await client.CallOkAsync( "upsert_resources", new Dictionary { ["yaml"] = _newServerYaml, ["dryRun"] = true }); Assert.Equal(["new-server"], response.Added); Assert.Contains("new-server", Assert.Contains("new-server", response.NewYaml)); Assert.Equal(before, api.StoredYaml); } [Fact] public async Task Merge_updates_fields_and_reports_old_and_new_yaml() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ImportYamlResponse response = await client.CallOkAsync( "upsert_resources", new Dictionary { ["yaml"] = """ version: 4 resources: - kind: System name: host-os cores: 16 """ }); Assert.Equal(["host-os"], response.Updated); Assert.Contains("cores: 8", response.OldYaml["host-os"]); Assert.Contains("cores: 16", response.NewYaml["host-os"]); Assert.Contains("cores: 16", api.StoredYaml); // Merge only adds and updates — everything not mentioned stays. Assert.Contains("env: prod", api.StoredYaml); Assert.Contains("ip: 10.0.0.5", api.StoredYaml); } [Fact] public async Task Replace_mode_swaps_the_resource_in_wholesale() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); ImportYamlResponse response = await client.CallOkAsync( "upsert_resources", new Dictionary { ["yaml"] = """ version: 4 resources: - kind: System name: host-os type: vm os: alpine """, ["mode"] = "Replace" }); Assert.Equal(["host-os"], response.Replaced); Assert.Contains("os: alpine", api.StoredYaml); Assert.DoesNotContain("env: prod", api.StoredYaml); // replaced, so the old labels are gone // Replace swaps the named resource only; the rest of the file is untouched. Assert.Contains("grafana", api.StoredYaml); } [Theory] [InlineData("not: [valid", "Import failed")] [InlineData("version: 4", "resources")] [InlineData("", "Invalid input")] public async Task Broken_documents_are_errors_that_name_the_problem(string yaml, string expected) { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var before = api.StoredYaml; var error = await client.CallErrorAsync( "upsert_resources", new Dictionary { ["yaml"] = yaml }); Assert.Contains(expected, error); Assert.Equal(before, api.StoredYaml); } [Fact] public async Task The_first_mcp_write_after_a_restart_does_not_destroy_the_existing_inventory() { // Same guarantee the inventory API pins down: the server loads the config before // serving, so a fresh boot's first merge happens against the user's file rather // than an empty collection (which would persist and wipe everything else). using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); await client.CallOkAsync( "upsert_resources", new Dictionary { ["yaml"] = _newServerYaml }); Assert.Contains("rack-server", api.StoredYaml); Assert.Contains("grafana", api.StoredYaml); Assert.Contains("new-server", api.StoredYaml); } // -- delete_resource ----------------------------------------------------------------- [Fact] public async Task Deleting_hardware_detaches_dependants_and_unplugs_its_connections() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); await client.CallTextAsync( "delete_resource", new Dictionary { ["name"] = "rack-server" }); var stored = api.StoredYaml; Assert.DoesNotContain("rack-server", stored); SchemaAssert.ConformsToSchema(stored); ConnectionList connections = await client.CallOkAsync("list_connections"); Assert.Equal(0, connections.Count); ResourceDetail hostOs = await client.CallOkAsync( "get_resource", new Dictionary { ["name"] = "host-os" }); Assert.DoesNotContain("rack-server", hostOs.Yaml); } [Fact] public async Task Deleting_something_that_does_not_exist_is_an_error() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var error = await client.CallErrorAsync( "delete_resource", new Dictionary { ["name"] = "ghost" }); Assert.Contains("ghost", error); } // -- rename_resource ----------------------------------------------------------------- [Fact] public async Task Renaming_rewrites_runs_on_links_and_connection_endpoints() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); await client.CallTextAsync("rename_resource", new Dictionary { ["name"] = "rack-server", ["newName"] = "compute-01" }); var stored = api.StoredYaml; Assert.DoesNotContain("rack-server", stored); SchemaAssert.ConformsToSchema(stored); ResourceDetail hostOs = await client.CallOkAsync( "get_resource", new Dictionary { ["name"] = "host-os" }); Assert.Contains("compute-01", hostOs.Yaml); ConnectionList connections = await client.CallOkAsync("list_connections"); Assert.Equal("compute-01", Assert.Single(connections.Connections).A.Resource); } [Fact] public async Task Renaming_onto_a_taken_name_is_a_conflict() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var error = await client.CallErrorAsync("rename_resource", new Dictionary { ["name"] = "rack-server", ["newName"] = "rack-switch" }); Assert.Contains("already exists", error); } // -- clone_resource ------------------------------------------------------------------ [Fact] public async Task A_clone_copies_the_kind_specific_fields_but_never_the_discovery_id() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); await client.CallTextAsync("clone_resource", new Dictionary { ["name"] = "rack-server", ["cloneName"] = "compute-02" }); ResourceDetail clone = await client.CallOkAsync( "get_resource", new Dictionary { ["name"] = "compute-02" }); Assert.Contains("kind: Server", clone.Yaml); // The deep copy went through the concrete Server type: the ports survived. Assert.Contains("ports:", clone.Yaml); Assert.Contains("count: 4", clone.Yaml); // A discoveryId names one machine; a copy of its card is not that machine. Assert.DoesNotContain("discoveryId", clone.Yaml); SchemaAssert.ConformsToSchema(api.StoredYaml); } [Fact] public async Task Cloning_guards_both_names() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); Assert.Contains("not found", await client.CallErrorAsync("clone_resource", new Dictionary { ["name"] = "ghost", ["cloneName"] = "copy" })); Assert.Contains("already exists", await client.CallErrorAsync("clone_resource", new Dictionary { ["name"] = "rack-server", ["cloneName"] = "rack-switch" })); } // -- edit_tags / edit_labels --------------------------------------------------------- [Fact] public async Task Tags_can_be_added_and_removed_in_one_call() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); TagsResult result = await client.CallOkAsync("edit_tags", new Dictionary { ["name"] = "rack-server", ["add"] = new[] { "rack-a", "critical" }, ["remove"] = new[] { "prod" } }); Assert.Equal(["rack-a", "critical"], result.Tags); Assert.Contains("rack-a", api.StoredYaml); Assert.DoesNotContain("- prod", api.StoredYaml); } [Fact] public async Task Labels_can_be_set_overwritten_and_removed() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); LabelsResult result = await client.CallOkAsync("edit_labels", new Dictionary { ["name"] = "host-os", ["set"] = new Dictionary { ["env"] = "staging", ["owner"] = "tim" }, ["remove"] = new[] { "owner" } }); Assert.Equal("staging", Assert.Contains("env", result.Labels)); Assert.DoesNotContain("owner", result.Labels.Keys); Assert.Contains("env: staging", api.StoredYaml); } [Fact] public async Task Editing_tags_or_labels_needs_something_to_do() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); Assert.Contains("at least one", await client.CallErrorAsync("edit_tags", new Dictionary { ["name"] = "rack-server" })); Assert.Contains("at least one", await client.CallErrorAsync("edit_labels", new Dictionary { ["name"] = "host-os" })); } // -- add_connection / remove_connection ------------------------------------------------ private static Dictionary Connect( string a, int groupA, int indexA, string b, int groupB, int indexB) => new() { ["resourceA"] = a, ["portGroupA"] = groupA, ["portIndexA"] = indexA, ["resourceB"] = b, ["portGroupB"] = groupB, ["portIndexB"] = indexB }; [Fact] public async Task Connecting_two_free_ports_is_persisted() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); await client.CallTextAsync("add_connection", Connect("rack-server", 0, 1, "rack-switch", 0, 2)); ConnectionList connections = await client.CallOkAsync("list_connections"); Assert.Equal(2, connections.Count); SchemaAssert.ConformsToSchema(api.StoredYaml); } [Fact] public async Task Connecting_an_occupied_port_replaces_what_was_plugged_into_it() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); // rack-server port 0/0 is already cabled to rack-switch 0/0 in the seed. await client.CallTextAsync("add_connection", Connect("rack-server", 0, 0, "rack-switch", 0, 5)); ConnectionList connections = await client.CallOkAsync("list_connections"); Connection connection = Assert.Single(connections.Connections); Assert.Equal(5, connection.B.PortIndex); } [Fact] public async Task Impossible_connections_are_named_errors() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); Assert.Contains("itself", await client.CallErrorAsync("add_connection", Connect("rack-server", 0, 0, "rack-server", 0, 0))); Assert.Contains("no ports", await client.CallErrorAsync("add_connection", Connect("host-os", 0, 0, "rack-switch", 0, 1))); Assert.Contains("not found", await client.CallErrorAsync("add_connection", Connect("rack-server", 0, 99, "rack-switch", 0, 1))); } [Fact] public async Task Removing_a_connection_unplugs_the_port_and_removing_again_is_a_no_op() { using var api = new McpFixture(TestData.Seed); await using McpClient client = await api.ConnectAsync(); var args = new Dictionary { ["resource"] = "rack-switch", ["portGroup"] = 0, ["portIndex"] = 0 }; await client.CallTextAsync("remove_connection", args); await client.CallTextAsync("remove_connection", args); // idempotent ConnectionList connections = await client.CallOkAsync("list_connections"); Assert.Equal(0, connections.Count); Assert.DoesNotContain("portIndex", api.StoredYaml); } }