Prechádzať zdrojové kódy

Add MCP server: manage, query and build the inventory from AI assistants

RackPeek.Web now hosts a Model Context Protocol server over streamable HTTP
at /mcp — no extra process, it runs whenever the server runs, and it sits
behind the same X-Api-Key gate as /api/inventory (503 until RPK_API_KEY is
set, so it is off by default).

24 consolidated tools in a new RackPeek.Mcp project (Domain-only deps, so
the CLI binaries stay MCP-free):

- Query: list/get/search resources, summary, containment tree, connections,
  subnets, and get_schema so agents learn the YAML format before writing.
- Editing: upsert_resources (bulk YAML with dry-run diffs — the main write
  path), delete/rename/clone, tag/label edits, connection add/remove.
- Exporters: ansible inventory, ssh config, hosts file, mermaid topology.
- Git: status + commit(+push), active on the existing GIT_TOKEN opt-in.
- Discovery: docker and proxmox run from the server with preview-then-apply;
  credentials come from server config, never from the conversation.

Every tool reuses the existing use cases, so validation, conflict rules,
discovery-id resolution and file locking behave exactly like the CLI and UI.
Domain errors surface as actionable tool errors; anything unexpected stays
generic. GlobalSearchService moves from Shared.Rcl to RackPeek.Domain/Search
so the search tool can reach it without UI dependencies.

Testing is end-to-end at two tiers: Tests.Mcp (70 tests) drives a real MCP
client over WebApplicationFactory through every tool down to the YAML on
disk, including fake docker/proxmox engines on real sockets and schema
conformance on everything the tools emit; Tests.E2e gains McpE2eTests
running a full session against the shipped Docker image over the network.
New mcp-tests CI job and `just test-mcp` target.

Also fixes a duplicate lounge-ap resource in the v3/v4 demo configs that
crashed `rpk graph topology` (surfaced by the new export tool tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 1 deň pred
rodič
commit
e1a7a6f5ad

+ 23 - 0
.github/workflows/test.yml

@@ -56,6 +56,29 @@ jobs:
         run: dotnet test Tests.Discovery --configuration Release --verbosity normal
 
 
+  mcp-tests:
+    name: MCP Tests
+    runs-on: ubuntu-latest
+    needs: format
+
+    # Every MCP test is end-to-end: a real MCP client speaking streamable HTTP to the
+    # real server over WebApplicationFactory, asserting on the YAML that lands on disk.
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore Tests.Mcp
+
+      - name: Run MCP Tests
+        run: dotnet test Tests.Mcp --configuration Release --verbosity normal
+
+
   cli-tests:
     name: CLI Tests
     runs-on: ubuntu-latest

+ 3 - 0
README.md

@@ -88,6 +88,9 @@ volumes:
 * 
   [**Auto Discovery Guide**](https://timmoth.github.io/RackPeek/docs/discovery-guide)
 
+* 
+  [**MCP Server Guide**](https://timmoth.github.io/RackPeek/docs/mcp-guide) — let AI assistants query, manage and build your stack over the built-in `/mcp` endpoint
+
 * 
   [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)
 

+ 1 - 1
Shared.Rcl/Services/GlobalSearchService.cs → RackPeek.Domain/Search/GlobalSearchService.cs

@@ -2,7 +2,7 @@ using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 
-namespace Shared.Rcl.Services;
+namespace RackPeek.Domain.Search;
 
 public record SearchResult(
     string Name,

+ 26 - 0
RackPeek.Mcp/McpSetup.cs

@@ -0,0 +1,26 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using RackPeek.Mcp.Tools;
+
+namespace RackPeek.Mcp;
+
+public static class McpSetup {
+    /// <summary>The name MCP clients see in the initialize handshake.</summary>
+    public const string ServerName = "rackpeek";
+
+    public static IMcpServerBuilder WithRackPeekTools(this IMcpServerBuilder builder) {
+        // MCP tool serialization does not go through ASP.NET's ConfigureHttpJsonOptions,
+        // so enums-as-strings is opted into again here to match the REST API's JSON.
+        var json = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions);
+        json.Converters.Add(new JsonStringEnumConverter());
+
+        return builder
+            .WithTools<QueryTools>(json)
+            .WithTools<MutationTools>(json)
+            .WithTools<ExportTools>(json)
+            .WithTools<GitTools>(json)
+            .WithTools<DiscoveryTools>(json);
+    }
+}

+ 30 - 0
RackPeek.Mcp/RackPeek.Mcp.csproj

@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net10.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <!-- MCP tool implementations over the domain use cases. This project is
+         referenced only by RackPeek.Web: the CLI (RackPeek -> Shared.Rcl ->
+         RackPeek.Domain) must stay free of the MCP SDK dependency so the
+         self-contained single-file binaries do not grow for a feature that
+         only exists on the server. -->
+
+    <ItemGroup>
+        <PackageReference Include="ModelContextProtocol" Version="2.2.0"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\RackPeek.Domain\RackPeek.Domain.csproj"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <!-- get_schema serves the schema for the CURRENT config version, resolved at
+             runtime from ListOfMigrations.Count. When a v5 migration lands, add its
+             schema here — the tool throws (and Tests.Mcp fails) until it is present. -->
+        <EmbeddedResource Include="..\schemas\v4\schema.v4.json" LogicalName="schema.v4.json"/>
+    </ItemGroup>
+
+</Project>

+ 54 - 0
RackPeek.Mcp/ResourceKindDispatch.cs

@@ -0,0 +1,54 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.UpsUnits;
+using RackPeek.Domain.UseCases;
+
+namespace RackPeek.Mcp;
+
+/// <summary>
+///     Closes the per-kind generic use cases over the concrete resource type at
+///     runtime. MCP tools receive a name, not a type — the kind is looked up and the
+///     call dispatched so type-sensitive code (the clone's deep copy serialises the
+///     real derived type, not the abstract base) runs against the right generic.
+/// </summary>
+internal static class ResourceKindDispatch {
+    public static async Task CloneAsync(
+        IServiceProvider services,
+        IResourceCollection repo,
+        string originalName,
+        string cloneName) {
+        var kind = await repo.GetKind(originalName)
+                   ?? throw new NotFoundException($"Resource '{originalName}' not found.");
+
+        await (kind.Trim().ToLowerInvariant() switch {
+            "server" => Run<Server>(),
+            "switch" => Run<Switch>(),
+            "firewall" => Run<Firewall>(),
+            "router" => Run<Router>(),
+            "accesspoint" => Run<AccessPoint>(),
+            "desktop" => Run<Desktop>(),
+            "laptop" => Run<Laptop>(),
+            "ups" => Run<Ups>(),
+            "other" => Run<Other>(),
+            "system" => Run<SystemResource>(),
+            "service" => Run<Service>(),
+            _ => throw new NotFoundException($"Resource kind '{kind}' cannot be cloned.")
+        });
+
+        Task Run<T>() where T : Resource =>
+            services.GetRequiredService<ICloneResourceUseCase<T>>()
+                .ExecuteAsync(originalName, cloneName);
+    }
+}

+ 33 - 0
RackPeek.Mcp/ToolErrors.cs

@@ -0,0 +1,33 @@
+using System.ComponentModel.DataAnnotations;
+using ModelContextProtocol;
+using RackPeek.Domain.Helpers;
+
+namespace RackPeek.Mcp;
+
+/// <summary>
+///     Runs a tool body and rethrows the domain's user-facing exceptions as
+///     <see cref="McpException" /> so their message reaches the calling agent as a
+///     tool error it can act on. Anything else falls through: the SDK reports those
+///     as a generic error, deliberately, so nothing internal leaks over the wire.
+/// </summary>
+internal static class ToolErrors {
+    public static async Task<T> RunAsync<T>(Func<Task<T>> action) {
+        try {
+            return await action();
+        }
+        catch (ValidationException ex) {
+            throw new McpException($"Invalid input: {ex.Message}");
+        }
+        catch (NotFoundException ex) {
+            throw new McpException(ex.Message);
+        }
+        catch (ConflictException ex) {
+            throw new McpException(ex.Message);
+        }
+        catch (InvalidOperationException ex) {
+            // The connection use cases signal user-correctable mistakes with this
+            // ("cannot connect a port to itself", "resource has no ports").
+            throw new McpException(ex.Message);
+        }
+    }
+}

+ 206 - 0
RackPeek.Mcp/Tools/DiscoveryTools.cs

@@ -0,0 +1,206 @@
+using System.ComponentModel;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record DiscoveryResult(
+    [property: Description("What was found, as a RackPeek YAML document ready for upsert_resources.")]
+    string Yaml,
+    int ResourceCount,
+    [property: Description("Containers that were skipped because they publish no port reachable from outside the host.")]
+    int Skipped,
+    [property: Description("The merge outcome when apply was true; null when only previewing.")]
+    ImportYamlResponse? Applied);
+
+/// <summary>
+///     Discovery run from the server against reachable infrastructure. Credentials
+///     are read from the server's own configuration, never from tool parameters, so
+///     secrets stay out of the conversation. `rpk discover system` has no tool here
+///     on purpose: it probes the machine it runs on, which for the server is its own
+///     container — run the CLI on the machine being inventoried instead.
+/// </summary>
+[McpServerToolType]
+public sealed class DiscoveryTools(IServiceProvider services) {
+    [McpServerTool(Name = "discover_docker", UseStructuredContent = true, OpenWorld = true)]
+    [Description("Reads a Docker (or Podman) engine and maps each container with a published port to a Service. " +
+                 "Preview first (apply=false), then apply to merge into the inventory — merging never removes anything.")]
+    public Task<DiscoveryResult> DiscoverDocker(
+        [Description("Docker endpoint, e.g. tcp://host:2375 or unix:///var/run/docker.sock. Defaults to DOCKER_HOST, then the local socket.")]
+        string? dockerHost = null,
+        [Description("Name of the machine the containers run on. Defaults to the engine's hostname.")]
+        string? hostName = null,
+        [Description("Merge the result into the inventory instead of only returning it.")]
+        bool apply = false,
+        CancellationToken cancellationToken = default) {
+        return ToolErrors.RunAsync(async () => {
+            SystemFacts host = await ReadHostAsync(cancellationToken);
+
+            DockerApiClient client;
+            try {
+                client = new DockerApiClient(dockerHost);
+            }
+            catch (UriFormatException ex) {
+                throw new McpException($"'{dockerHost}' is not a usable Docker endpoint. {ex.Message}");
+            }
+
+            using DockerApiClient _ = client;
+
+            IReadOnlyList<DockerContainer> containers;
+            try {
+                containers = await client.ListContainersAsync(cancellationToken);
+            }
+            catch (Exception ex) when (
+                ex is HttpRequestException or IOException or TimeoutException
+                || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+                throw new McpException($"Could not reach Docker at {client.Endpoint}. {ex.Message}");
+            }
+
+            // Mirrors `rpk discover docker`: over TCP the engine describes itself (its
+            // daemon id seeds identity, its hostname is what runsOn points at); locally
+            // the host probe does, and the host System rides along for id-based merging.
+            DockerEngineInfo? engine = client.IsLocal ? null : await client.GetInfoAsync(cancellationToken);
+
+            SystemResource hostResource = SystemResourceMapper.ToResource(host, hostName);
+
+            var effectiveHost = client.IsLocal
+                ? hostResource.Name
+                : hostName ?? engine?.Hostname ?? hostResource.Name;
+
+            var seed = client.IsLocal
+                ? host.MachineId ?? host.Hostname
+                : engine?.Id ?? client.Endpoint;
+
+            var serviceIp = client.IsLocal
+                ? host.Ip
+                : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip;
+
+            List<Service> found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp);
+
+            List<Resource> resources = client.IsLocal && found.Count > 0
+                ? [hostResource, .. found]
+                : [.. found];
+
+            return await EmitAsync(resources, containers.Count - found.Count, apply);
+        });
+    }
+
+    [McpServerTool(Name = "discover_proxmox", UseStructuredContent = true, OpenWorld = true)]
+    [Description("Reads a Proxmox VE estate: each node becomes a Server plus a hypervisor System, each VM/LXC a System " +
+                 "running on it, already wired together. Credentials come from the server's RPK_PVE_TOKEN_ID / " +
+                 "RPK_PVE_TOKEN_SECRET configuration. Preview first (apply=false), then apply to merge.")]
+    public Task<DiscoveryResult> DiscoverProxmox(
+        [Description("Proxmox host, e.g. https://pve.lan:8006. A bare host name gets https and :8006.")]
+        string host,
+        [Description("Accept a self-signed certificate, which Proxmox ships with by default.")]
+        bool insecure = false,
+        [Description("Merge the result into the inventory instead of only returning it.")]
+        bool apply = false,
+        CancellationToken cancellationToken = default) {
+        return ToolErrors.RunAsync(async () => {
+            IConfiguration config = services.GetRequiredService<IConfiguration>();
+            var tokenId = config[ProxmoxApiClient.TokenIdEnvironmentVariable];
+            var tokenSecret = config[ProxmoxApiClient.TokenSecretEnvironmentVariable];
+
+            if (string.IsNullOrWhiteSpace(tokenId) || string.IsNullOrWhiteSpace(tokenSecret))
+                throw new McpException(
+                    "Proxmox credentials are not configured on the server. Start it with " +
+                    $"{ProxmoxApiClient.TokenIdEnvironmentVariable} and {ProxmoxApiClient.TokenSecretEnvironmentVariable} set.");
+
+            ProxmoxApiClient client;
+            try {
+                client = new ProxmoxApiClient(host, tokenId, tokenSecret, insecure);
+            }
+            catch (UriFormatException ex) {
+                throw new McpException($"'{host}' is not a usable host. {ex.Message}");
+            }
+
+            List<Resource> resources;
+            try {
+                resources = await ReadProxmoxAsync(client, cancellationToken);
+            }
+            catch (HttpRequestException ex) {
+                var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException
+                    ? " Proxmox uses a self-signed certificate by default — try insecure=true."
+                    : string.Empty;
+
+                throw new McpException($"Could not read {client.Endpoint}. {ex.Message}{hint}");
+            }
+            catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) {
+                // HttpClient reports its timeout as a cancellation.
+                throw new McpException($"{client.Endpoint} did not answer within the timeout.");
+            }
+            finally {
+                client.Dispose();
+            }
+
+            return await EmitAsync(resources, 0, apply);
+        });
+    }
+
+    private async Task<SystemFacts> ReadHostAsync(CancellationToken cancellationToken) {
+        // An unsupported platform is not fatal for docker discovery — the containers can
+        // still be read; only the host's own facts fall back to basics.
+        return await SystemProbes.TryReadHostAsync(services.GetServices<ISystemProbe>(), cancellationToken)
+               ?? SystemFactsParser.Parse(new RawSystemSnapshot {
+                   Hostname = Environment.MachineName,
+                   Cores = Environment.ProcessorCount
+               });
+    }
+
+    /// <summary>Same read orchestration as `rpk discover proxmox`.</summary>
+    private static async Task<List<Resource>> ReadProxmoxAsync(
+        IProxmoxClient client,
+        CancellationToken cancellationToken) {
+        var scope = await client.GetIdentityScopeAsync(cancellationToken);
+        IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
+
+        var nodes = new List<ProxmoxNode>();
+        var guests = new List<ProxmoxGuest>();
+
+        foreach (ProxmoxNode listedNode in listed) {
+            ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
+            nodes.Add(node);
+
+            foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
+                IReadOnlyList<ProxmoxGuest> listedGuests =
+                    await client.GetGuestsAsync(node.Name, endpoint, cancellationToken);
+
+                ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
+                    client.GetGuestConfigAsync(node.Name, endpoint, g.VmId, cancellationToken)));
+
+                for (var i = 0; i < listedGuests.Count; i++)
+                    guests.Add(listedGuests[i] with {
+                        Os = configs[i].Os,
+                        Ip = configs[i].Ip,
+                        Disks = configs[i].DiskBytes,
+                        PassthroughAddresses = configs[i].PassthroughAddresses
+                    });
+            }
+        }
+
+        return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
+    }
+
+    private async Task<DiscoveryResult> EmitAsync(List<Resource> resources, int skipped, bool apply) {
+        var yaml = DiscoveryDocument.ToYaml(resources);
+
+        ImportYamlResponse? applied = null;
+        if (apply && resources.Count > 0)
+            applied = await MutationTools.RunUpsertAsync(services, new ImportYamlRequest {
+                Yaml = yaml,
+                // Discovery can add and update but must never remove what the user wrote.
+                Mode = MergeMode.Merge
+            });
+
+        return new DiscoveryResult(yaml, resources.Count, skipped, applied);
+    }
+}

+ 83 - 0
RackPeek.Mcp/Tools/ExportTools.cs

@@ -0,0 +1,83 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Graph;
+using RackPeek.Domain.Graph.Serialisers;
+using RackPeek.Domain.Graph.UseCases;
+using RackPeek.Domain.UseCases.Ansible;
+using RackPeek.Domain.UseCases.Hosts;
+using RackPeek.Domain.UseCases.SSH;
+
+namespace RackPeek.Mcp.Tools;
+
+public enum TopologyView {
+    Physical,
+    Logical
+}
+
+[McpServerToolType]
+public sealed class ExportTools(IServiceProvider services) {
+    [McpServerTool(Name = "export_ansible_inventory", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the inventory as an Ansible inventory file. A resource needs an 'ansible_host', 'ip' or " +
+                 "'hostname' label to be included, and hosts are emitted under the groups built from groupByTags / " +
+                 "groupByLabelKeys — pass at least one of those or the inventory comes back empty.")]
+    public Task<InventoryResult?> ExportAnsibleInventory(
+        [Description("Output format: Ini or Yaml.")] InventoryFormat format = InventoryFormat.Ini,
+        [Description("Create a group per listed tag.")] string[]? groupByTags = null,
+        [Description("Create groups from these label keys, e.g. 'env' groups hosts into env_prod, env_dev.")]
+        string[]? groupByLabelKeys = null) {
+        return ToolErrors.RunAsync(() =>
+            services.GetRequiredService<AnsibleInventoryGeneratorUseCase>().ExecuteAsync(new InventoryOptions {
+                Format = format,
+                GroupByTags = groupByTags ?? [],
+                GroupByLabelKeys = groupByLabelKeys ?? []
+            }));
+    }
+
+    [McpServerTool(Name = "export_ssh_config", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the inventory as an OpenSSH client config (Host blocks).")]
+    public Task<SshExportResult?> ExportSshConfig(
+        [Description("Only include resources carrying at least one of these tags.")]
+        string[]? includeTags = null,
+        [Description("Default SSH user for every host.")] string? defaultUser = null,
+        [Description("Default SSH port for every host.")] int defaultPort = 22,
+        [Description("Default IdentityFile path for every host.")] string? defaultIdentityFile = null) {
+        return ToolErrors.RunAsync(() =>
+            services.GetRequiredService<SshConfigExportUseCase>().ExecuteAsync(new SshExportOptions {
+                IncludeTags = includeTags ?? [],
+                DefaultUser = defaultUser,
+                DefaultPort = defaultPort,
+                DefaultIdentityFile = defaultIdentityFile
+            }));
+    }
+
+    [McpServerTool(Name = "export_hosts_file", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the inventory as /etc/hosts entries.")]
+    public Task<HostsExportResult?> ExportHostsFile(
+        [Description("Only include resources carrying at least one of these tags.")]
+        string[]? includeTags = null,
+        [Description("Domain suffix appended to every host name, e.g. 'home.local'.")]
+        string? domainSuffix = null,
+        [Description("Include the localhost entries at the top.")]
+        bool includeLocalhostDefaults = true) {
+        return ToolErrors.RunAsync(() =>
+            services.GetRequiredService<HostsFileExportUseCase>().ExecuteAsync(new HostsExportOptions {
+                IncludeTags = includeTags ?? [],
+                DomainSuffix = domainSuffix,
+                IncludeLocalhostDefaults = includeLocalhostDefaults
+            }));
+    }
+
+    [McpServerTool(Name = "export_topology_mermaid", ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the infrastructure as a Mermaid diagram: Physical shows hardware and its port connections, Logical shows host cards with their services grouped by subnet.")]
+    public Task<string> ExportTopologyMermaid(
+        [Description("Physical or Logical.")] TopologyView view = TopologyView.Physical) {
+        return ToolErrors.RunAsync(async () => {
+            Graph graph = view == TopologyView.Physical
+                ? await services.GetRequiredService<BuildPhysicalTopologyUseCase>().ExecuteAsync()
+                : await services.GetRequiredService<BuildLogicalGraphUseCase>().ExecuteAsync();
+
+            return new MermaidSerialiser().Serialise(graph);
+        });
+    }
+}

+ 95 - 0
RackPeek.Mcp/Tools/GitTools.cs

@@ -0,0 +1,95 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record GitStatusResult(
+    [property: Description("False when the server has no GIT_TOKEN configured — every other field is then empty.")]
+    bool Available,
+    string? Message,
+    string? Branch = null,
+    [property: Description("Clean or Dirty.")] string? Status = null,
+    string[]? ChangedFiles = null,
+    bool HasRemote = false,
+    [property: Description("Commits the remote is missing. Null when there is no remote.")]
+    int? Ahead = null,
+    [property: Description("Commits this repo is missing. Null when there is no remote.")]
+    int? Behind = null,
+    List<string>? RecentCommits = null);
+
+/// <summary>
+///     Version control over the config directory. Only active when the server is
+///     started with GIT_TOKEN — otherwise <see cref="NullGitRepository" /> is wired
+///     in and these tools explain how to enable it instead of failing obscurely.
+/// </summary>
+[McpServerToolType]
+public sealed class GitTools(IGitRepository repo, IServiceProvider services) {
+    private const string _notConfigured =
+        "Git integration is not configured. Start the server with GIT_TOKEN " +
+        "(and optionally GIT_USERNAME) set to enable it.";
+
+    [McpServerTool(Name = "git_status", UseStructuredContent = true, ReadOnly = true, OpenWorld = false)]
+    [Description("The config repository's branch, dirty/clean state, changed files, remote sync state and recent commits.")]
+    public Task<GitStatusResult> GitStatus() {
+        return ToolErrors.RunAsync(() => {
+            if (!repo.IsAvailable)
+                return Task.FromResult(new GitStatusResult(false, _notConfigured));
+
+            try {
+                var hasRemote = repo.HasRemote();
+                GitSyncStatus? sync = hasRemote ? repo.FetchAndGetSyncStatus() : null;
+
+                GitLogEntry[] log;
+                try {
+                    log = repo.GetLog(5);
+                }
+                catch {
+                    // An empty repository has no log yet; that is not an error.
+                    log = [];
+                }
+
+                return Task.FromResult(new GitStatusResult(
+                    true,
+                    sync?.Error,
+                    repo.GetCurrentBranch(),
+                    repo.GetStatus().ToString(),
+                    repo.GetChangedFiles(),
+                    hasRemote,
+                    sync?.Ahead,
+                    sync?.Behind,
+                    log.Select(e => $"{e.Hash} {e.Date} {e.Author}: {e.Message}").ToList()));
+            }
+            catch (Exception ex) when (ex is not McpException) {
+                throw new McpException($"Git error: {ex.Message}");
+            }
+        });
+    }
+
+    [McpServerTool(Name = "git_commit", Idempotent = true, OpenWorld = false)]
+    [Description("Stages everything in the config directory and commits it. A clean tree commits nothing and still succeeds.")]
+    public Task<string> GitCommit(
+        [Description("The commit message.")] string message,
+        [Description("Also push to the configured remote.")] bool push = false) {
+        return ToolErrors.RunAsync(async () => {
+            if (!repo.IsAvailable)
+                throw new McpException(_notConfigured);
+
+            var error = await services.GetRequiredService<CommitAllUseCase>().ExecuteAsync(message);
+            if (error != null)
+                throw new McpException(error);
+
+            if (!push)
+                return "Committed.";
+
+            var pushError = await services.GetRequiredService<PushUseCase>().ExecuteAsync();
+            if (pushError != null)
+                throw new McpException($"Committed, but the push failed: {pushError}");
+
+            return "Committed and pushed.";
+        });
+    }
+}

+ 170 - 0
RackPeek.Mcp/Tools/MutationTools.cs

@@ -0,0 +1,170 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.UseCases;
+using RackPeek.Domain.UseCases.Labels;
+using RackPeek.Domain.UseCases.Tags;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record TagsResult(string Name, string[] Tags);
+
+public sealed record LabelsResult(string Name, Dictionary<string, string> Labels);
+
+[McpServerToolType]
+public sealed class MutationTools(IResourceCollection repo, IServiceProvider services) {
+    [McpServerTool(Name = "upsert_resources", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
+    [Description("Creates or updates resources (and connections) from a RackPeek YAML document — the main " +
+                 "way to build and edit the inventory. Call get_schema first to learn the format, and " +
+                 "preview with dryRun before writing. Merge mode only adds and updates; it never removes.")]
+    public Task<ImportYamlResponse> UpsertResources(
+        [Description("A RackPeek YAML document: 'version', 'resources' and optional 'connections'.")]
+        string yaml,
+        [Description("Merge folds each incoming resource into the stored one, field by field. " +
+                     "Replace swaps each incoming resource in wholesale (other resources are untouched).")]
+        MergeMode mode = MergeMode.Merge,
+        [Description("When true, nothing is written — the response shows what would change.")]
+        bool dryRun = false) =>
+        RunUpsertAsync(services, new ImportYamlRequest { Yaml = yaml, Mode = mode, DryRun = dryRun });
+
+    [McpServerTool(Name = "delete_resource", Destructive = true, Idempotent = true, OpenWorld = false)]
+    [Description("Deletes a resource, detaches everything that ran on it, and removes its connections.")]
+    public Task<string> DeleteResource(
+        [Description("The resource's name.")] string name) {
+        return ToolErrors.RunAsync(async () => {
+            await services.GetRequiredService<IDeleteResourceUseCase<Resource>>().ExecuteAsync(name);
+            return $"Deleted '{name}'.";
+        });
+    }
+
+    [McpServerTool(Name = "rename_resource", Idempotent = true, OpenWorld = false)]
+    [Description("Renames a resource and rewrites every runsOn reference and connection endpoint that pointed at it.")]
+    public Task<string> RenameResource(
+        [Description("The resource's current name.")] string name,
+        [Description("The new name.")] string newName) {
+        return ToolErrors.RunAsync(async () => {
+            await services.GetRequiredService<IRenameResourceUseCase<Resource>>().ExecuteAsync(name, newName);
+            return $"Renamed '{name}' to '{newName}'.";
+        });
+    }
+
+    [McpServerTool(Name = "clone_resource", OpenWorld = false)]
+    [Description("Copies a resource under a new name. The copy has no discovery id — it describes new gear, not the original machine.")]
+    public Task<string> CloneResource(
+        [Description("The resource to copy.")] string name,
+        [Description("The copy's name.")] string cloneName) {
+        return ToolErrors.RunAsync(async () => {
+            await ResourceKindDispatch.CloneAsync(services, repo, name, cloneName);
+            return $"Cloned '{name}' to '{cloneName}'.";
+        });
+    }
+
+    [McpServerTool(Name = "edit_tags", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
+    [Description("Adds and/or removes tags on a resource and returns the tags it ends up with.")]
+    public Task<TagsResult> EditTags(
+        [Description("The resource's name.")] string name,
+        [Description("Tags to add.")] string[]? add = null,
+        [Description("Tags to remove.")] string[]? remove = null) {
+        return ToolErrors.RunAsync(async () => {
+            if (add is not { Length: > 0 } && remove is not { Length: > 0 })
+                throw new ValidationException("Pass at least one tag to add or remove.");
+
+            foreach (var tag in add ?? [])
+                await services.GetRequiredService<IAddTagUseCase<Resource>>().ExecuteAsync(name, tag);
+
+            foreach (var tag in remove ?? [])
+                await services.GetRequiredService<IRemoveTagUseCase<Resource>>().ExecuteAsync(name, tag);
+
+            Resource resource = await repo.GetByNameAsync(name)
+                                ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+            return new TagsResult(resource.Name, resource.Tags);
+        });
+    }
+
+    [McpServerTool(Name = "edit_labels", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
+    [Description("Sets and/or removes key-value labels on a resource and returns the labels it ends up with.")]
+    public Task<LabelsResult> EditLabels(
+        [Description("The resource's name.")] string name,
+        [Description("Labels to set — an existing key is overwritten.")]
+        Dictionary<string, string>? set = null,
+        [Description("Label keys to remove.")] string[]? remove = null) {
+        return ToolErrors.RunAsync(async () => {
+            if (set is not { Count: > 0 } && remove is not { Length: > 0 })
+                throw new ValidationException("Pass at least one label to set or remove.");
+
+            foreach ((var key, var value) in set ?? new Dictionary<string, string>())
+                await services.GetRequiredService<IAddLabelUseCase<Resource>>().ExecuteAsync(name, key, value);
+
+            foreach (var key in remove ?? [])
+                await services.GetRequiredService<IRemoveLabelUseCase<Resource>>().ExecuteAsync(name, key);
+
+            Resource resource = await repo.GetByNameAsync(name)
+                                ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+            return new LabelsResult(resource.Name, resource.Labels);
+        });
+    }
+
+    [McpServerTool(Name = "add_connection", Idempotent = true, OpenWorld = false)]
+    [Description("Connects a port on one hardware resource to a port on another. A port holds one connection — " +
+                 "connecting an occupied port replaces what was plugged into it. Port groups and indexes are " +
+                 "zero-based against the resource's 'ports' list (a group entry with count 4 has indexes 0-3).")]
+    public Task<string> AddConnection(
+        [Description("First endpoint's resource name.")] string resourceA,
+        [Description("First endpoint's port group index.")] int portGroupA,
+        [Description("First endpoint's port index within the group.")] int portIndexA,
+        [Description("Second endpoint's resource name.")] string resourceB,
+        [Description("Second endpoint's port group index.")] int portGroupB,
+        [Description("Second endpoint's port index within the group.")] int portIndexB,
+        [Description("Optional label, e.g. 'uplink'.")] string? label = null,
+        [Description("Optional notes.")] string? notes = null) {
+        return ToolErrors.RunAsync(async () => {
+            var a = new PortReference { Resource = resourceA, PortGroup = portGroupA, PortIndex = portIndexA };
+            var b = new PortReference { Resource = resourceB, PortGroup = portGroupB, PortIndex = portIndexB };
+
+            await services.GetRequiredService<IAddConnectionUseCase>().ExecuteAsync(a, b, label, notes);
+
+            return $"Connected {ConnectionMerger.Describe(new Connection { A = a, B = b, Label = label })}.";
+        });
+    }
+
+    [McpServerTool(Name = "remove_connection", Destructive = true, Idempotent = true, OpenWorld = false)]
+    [Description("Removes whatever connection is plugged into the given port.")]
+    public Task<string> RemoveConnection(
+        [Description("The port's resource name.")] string resource,
+        [Description("The port's group index.")] int portGroup,
+        [Description("The port's index within the group.")] int portIndex) {
+        return ToolErrors.RunAsync(async () => {
+            var port = new PortReference { Resource = resource, PortGroup = portGroup, PortIndex = portIndex };
+            await services.GetRequiredService<IRemoveConnectionUseCase>().ExecuteAsync(port);
+            return $"Removed any connection on {resource} port {portGroup}/{portIndex}.";
+        });
+    }
+
+    /// <summary>
+    ///     Upserts get their own error mapping: the use case reports YAML/JSON problems
+    ///     through several exception types, and — matching the REST endpoint's contract —
+    ///     the message is always safe and useful to show the caller.
+    /// </summary>
+    internal static async Task<ImportYamlResponse> RunUpsertAsync(
+        IServiceProvider services,
+        ImportYamlRequest request) {
+        try {
+            return await services.GetRequiredService<UpsertInventoryUseCase>().ExecuteAsync(request);
+        }
+        catch (ValidationException ex) {
+            throw new McpException($"Invalid input: {ex.Message}");
+        }
+        catch (Exception ex) when (ex is not McpException) {
+            throw new McpException($"Import failed: {ex.Message}");
+        }
+    }
+}

+ 220 - 0
RackPeek.Mcp/Tools/QueryTools.cs

@@ -0,0 +1,220 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services.UseCases;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+using RackPeek.Domain.Search;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record ResourceList(int Count, List<ResourceRow> Resources);
+
+public sealed record SearchResults(IReadOnlyList<SearchResult> Matches);
+
+public sealed record TreeResult(List<HardwareTree> Hardware);
+
+public sealed record ConnectionList(int Count, IReadOnlyList<Connection> Connections);
+
+public sealed record ResourceRow(
+    string Name,
+    string Kind,
+    string? Ip,
+    List<string> RunsOn,
+    string[] Tags,
+    Dictionary<string, string> Labels);
+
+public sealed record ResourceDetail(
+    [property: Description("The resource and its connections as a RackPeek YAML document. " +
+                           "Edit it and pass it back to upsert_resources to change the resource.")]
+    string Yaml,
+    [property: Description("Names of resources that run on this one.")]
+    List<string> Dependants);
+
+public sealed record InfrastructureSummary(
+    HardwareSummary Hardware,
+    SystemSummary Systems,
+    AllServicesSummary Services,
+    [property: Description("Every tag in use and how many resources carry it.")]
+    Dictionary<string, int> Tags,
+    [property: Description("Every label key in use and how many resources carry it.")]
+    Dictionary<string, int> Labels);
+
+public sealed record SchemaInfo(
+    [property: Description("The current config schema version.")]
+    int Version,
+    [property: Description("The JSON schema every inventory YAML document must conform to.")]
+    string JsonSchema,
+    string Guidance);
+
+[McpServerToolType]
+public sealed class QueryTools(IResourceCollection repo, IServiceProvider services) {
+    internal const string UpsertGuidance =
+        "A RackPeek document is YAML with 'version', 'resources' and optional 'connections'. " +
+        "Every resource has 'kind' (Server, Switch, Firewall, Router, Accesspoint, Desktop, " +
+        "Laptop, Ups, Other, System, Service), a unique 'name' (max 50 chars), and optional " +
+        "'tags', 'labels', 'notes' and 'runsOn'. Containment rules: a Service runs on a " +
+        "System; a System runs on hardware or on another System. Pass documents to " +
+        "upsert_resources — merge mode only adds and updates, it never removes fields, " +
+        "so use the dedicated tools (delete_resource, edit_tags, edit_labels, " +
+        "remove_connection) to take things away.";
+
+    [McpServerTool(Name = "list_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Lists inventory resources with their key facts. All filters are optional and combine.")]
+    public Task<ResourceList> ListResources(
+        [Description("Only this kind: Server, Switch, Firewall, Router, Accesspoint, Desktop, Laptop, Ups, Other, System or Service.")]
+        string? kind = null,
+        [Description("Only resources carrying this tag.")]
+        string? tag = null,
+        [Description("Only resources carrying this label key.")]
+        string? labelKey = null) {
+        return ToolErrors.RunAsync(async () => {
+            IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
+            IReadOnlyList<(Resource, string)> ips = await repo.GetResourceIpsAsync();
+
+            var ipByName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            foreach ((Resource resource, var ip) in ips) ipByName[resource.Name] = ip;
+
+            var rows = all
+                .Where(r => kind is null || r.Kind.Equals(kind.Trim(), StringComparison.OrdinalIgnoreCase))
+                .Where(r => tag is null || r.Tags.Contains(tag.Trim(), StringComparer.OrdinalIgnoreCase))
+                .Where(r => labelKey is null || r.Labels.Keys.Contains(labelKey.Trim(), StringComparer.OrdinalIgnoreCase))
+                .OrderBy(r => r.Kind, StringComparer.OrdinalIgnoreCase)
+                .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(r => new ResourceRow(
+                    r.Name,
+                    r.Kind,
+                    ipByName.GetValueOrDefault(r.Name),
+                    r.RunsOn,
+                    r.Tags,
+                    r.Labels))
+                .ToList();
+
+            return new ResourceList(rows.Count, rows);
+        });
+    }
+
+    [McpServerTool(Name = "get_resource", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Gets one resource in full, as a YAML document that can be edited and passed back to upsert_resources.")]
+    public Task<ResourceDetail> GetResource(
+        [Description("The resource's name.")] string name) {
+        return ToolErrors.RunAsync(async () => {
+            Resource resource = await repo.GetByNameAsync(name)
+                                ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+            IReadOnlyList<Connection> connections = await repo.GetConnectionsForResourceAsync(resource.Name);
+            IReadOnlyList<Resource> dependants = await repo.GetDependantsAsync(resource.Name);
+
+            var yaml = YamlResourceCollection.SerializeRootAsync(new YamlRoot {
+                Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+                Resources = [resource],
+                Connections = connections.ToList()
+            });
+
+            return new ResourceDetail(yaml, dependants.Select(d => d.Name).ToList());
+        });
+    }
+
+    [McpServerTool(Name = "search_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Free-text search over resource names, IPs, tags and labels; returns the best matches first.")]
+    public Task<SearchResults> SearchResources(
+        [Description("The search text.")] string query,
+        [Description("Maximum number of matches to return.")] int max = 8) {
+        return ToolErrors.RunAsync(async () => {
+            IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
+            return new SearchResults(GlobalSearchService.Search(all, query, max));
+        });
+    }
+
+    [McpServerTool(Name = "get_summary", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Counts of everything in the inventory: hardware by kind, systems by type and OS, services, tags and labels.")]
+    public Task<InfrastructureSummary> GetSummary() {
+        return ToolErrors.RunAsync(async () => {
+            HardwareSummary hardware = await services.GetRequiredService<GetHardwareUseCaseSummary>().ExecuteAsync();
+            SystemSummary systems = await services.GetRequiredService<GetSystemSummaryUseCase>().ExecuteAsync();
+            AllServicesSummary allServices = await services.GetRequiredService<GetServiceSummaryUseCase>().ExecuteAsync();
+            Dictionary<string, int> tags = await repo.GetTagsAsync();
+            Dictionary<string, int> labels = await repo.GetLabelsAsync();
+
+            return new InfrastructureSummary(hardware, systems, allServices, tags, labels);
+        });
+    }
+
+    [McpServerTool(Name = "get_tree", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("The containment forest: each hardware resource, the systems running on it, and the services running on those.")]
+    public Task<TreeResult> GetTree(
+        [Description("Only the tree under this hardware resource. Omit for the whole forest.")]
+        string? hardwareName = null) {
+        return ToolErrors.RunAsync(async () => {
+            List<HardwareTree> forest = await services.GetRequiredService<IHardwareRepository>().GetTreeAsync();
+
+            if (hardwareName is null) return new TreeResult(forest);
+
+            var match = forest
+                .Where(t => t.HardwareName.Equals(hardwareName.Trim(), StringComparison.OrdinalIgnoreCase))
+                .ToList();
+
+            if (match.Count == 0)
+                throw new NotFoundException($"Hardware '{hardwareName}' not found.");
+
+            return new TreeResult(match);
+        });
+    }
+
+    [McpServerTool(Name = "list_connections", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Physical port-to-port connections between hardware resources.")]
+    public Task<ConnectionList> ListConnections(
+        [Description("Only connections touching this resource. Omit for all connections.")]
+        string? resource = null) {
+        return ToolErrors.RunAsync(async () => {
+            IReadOnlyList<Connection> connections = resource is null
+                ? await repo.GetConnectionsAsync()
+                : await repo.GetConnectionsForResourceAsync(resource);
+
+            return new ConnectionList(connections.Count, connections);
+        });
+    }
+
+    [McpServerTool(Name = "get_subnets", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Groups service IPs into subnets, or lists the services inside one CIDR block.")]
+    public Task<ServiceSubnetsResult> GetSubnets(
+        [Description("A CIDR block like 192.168.1.0/24 to list the services inside it. Omit to group all service IPs into subnets instead.")]
+        string? cidr = null,
+        [Description("Prefix length used to group when no CIDR is given (default 24).")]
+        int? prefix = null,
+        CancellationToken cancellationToken = default) {
+        return ToolErrors.RunAsync(async () => {
+            ServiceSubnetsResult result = await services.GetRequiredService<ServiceSubnetsUseCase>()
+                .ExecuteAsync(cidr, prefix, cancellationToken);
+
+            if (result.IsInvalidCidr)
+                throw new McpException($"'{result.InvalidCidrValue}' is not a valid CIDR block. Use e.g. 192.168.1.0/24.");
+
+            return result;
+        });
+    }
+
+    [McpServerTool(Name = "get_schema", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("The JSON schema and authoring rules for RackPeek inventory YAML — read this before writing documents for upsert_resources.")]
+    public Task<SchemaInfo> GetSchema() {
+        return ToolErrors.RunAsync(() => {
+            var version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
+            var resourceName = $"schema.v{version}.json";
+
+            using Stream? stream = typeof(QueryTools).Assembly.GetManifestResourceStream(resourceName);
+            if (stream is null)
+                throw new InvalidOperationException($"Embedded schema '{resourceName}' is missing from the build.");
+
+            using var reader = new StreamReader(stream);
+            var schema = reader.ReadToEnd();
+
+            return Task.FromResult(new SchemaInfo(version, schema, UpsertGuidance));
+        });
+    }
+}

+ 19 - 0
RackPeek.Web/Program.cs

@@ -6,6 +6,8 @@ using RackPeek.Domain;
 using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
+using ModelContextProtocol.AspNetCore;
+using RackPeek.Mcp;
 using RackPeek.Web.Api;
 using RackPeek.Web.Components;
 using Shared.Rcl;
@@ -88,6 +90,17 @@ public class Program {
         builder.Services.AddCommands();
         builder.Services.AddScoped<IConsoleEmulator, ConsoleEmulator>();
 
+        // MCP server, exposed over streamable HTTP at /mcp whenever the web server
+        // runs. Stateless: every call is a plain POST (no session affinity behind a
+        // reverse proxy) and tools resolve their services from the request scope,
+        // exactly like the inventory API does.
+        builder.Services.AddMcpServer(options => options.ServerInfo = new() {
+            Name = McpSetup.ServerName,
+            Version = RpkConstants.Version
+        })
+            .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless)
+            .WithRackPeekTools();
+
         // Razor Components
         builder.Services.AddRazorComponents()
             .AddInteractiveServerComponents();
@@ -126,6 +139,12 @@ public class Program {
 
         app.MapInventoryApi();
 
+        // Same key, same gate as /api/inventory: 503 until RPK_API_KEY is configured,
+        // so MCP is off by default and never exposes the inventory unauthenticated.
+        RouteGroupBuilder mcp = app.MapGroup("/mcp");
+        mcp.AddEndpointFilter<ApiKeyEndpointFilter>();
+        mcp.MapMcp();
+
         app.MapStaticAssets();
 
         app.MapRazorComponents<App>()

+ 6 - 1
RackPeek.Web/RackPeek.Web.csproj

@@ -13,7 +13,8 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <ProjectReference Include="..\Shared.Rcl\Shared.Rcl.csproj"/>
+        <ProjectReference Include="..\Shared.Rcl\Shared.Rcl.csproj" />
+        <ProjectReference Include="..\RackPeek.Mcp\RackPeek.Mcp.csproj" />
     </ItemGroup>
 
     <ItemGroup>
@@ -22,4 +23,8 @@
         </Content>
     </ItemGroup>
 
+    <ItemGroup>
+      <PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
+    </ItemGroup>
+
 </Project>

+ 99 - 0
RackPeek.sln

@@ -16,43 +16,142 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2e", "Tests.E2e\Test
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Discovery", "Tests.Discovery\Tests.Discovery.csproj", "{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RackPeek.Mcp", "RackPeek.Mcp\RackPeek.Mcp.csproj", "{4038A024-B71F-4126-9310-5DC109AD5616}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Mcp", "Tests.Mcp\Tests.Mcp.csproj", "{F82DD17A-0997-44B3-B3A3-15FC9E676427}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
+		Debug|x64 = Debug|x64
+		Debug|x86 = Debug|x86
 		Release|Any CPU = Release|Any CPU
+		Release|x64 = Release|x64
+		Release|x86 = Release|x86
 	EndGlobalSection
 	GlobalSection(ProjectConfigurationPlatforms) = postSolution
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x64.Build.0 = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x86.Build.0 = Debug|Any CPU
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x64.ActiveCfg = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x64.Build.0 = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x86.ActiveCfg = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x86.Build.0 = Release|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x64.Build.0 = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x86.Build.0 = Debug|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|Any CPU.Build.0 = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x64.ActiveCfg = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x64.Build.0 = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x86.ActiveCfg = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x86.Build.0 = Release|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x64.Build.0 = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x86.Build.0 = Debug|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|Any CPU.Build.0 = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x64.ActiveCfg = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x64.Build.0 = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x86.ActiveCfg = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x86.Build.0 = Release|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x64.Build.0 = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x86.Build.0 = Debug|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x64.ActiveCfg = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x64.Build.0 = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x86.ActiveCfg = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x86.Build.0 = Release|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x64.Build.0 = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x86.Build.0 = Debug|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|Any CPU.Build.0 = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x64.ActiveCfg = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x64.Build.0 = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x86.ActiveCfg = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x86.Build.0 = Release|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x64.Build.0 = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x86.Build.0 = Debug|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|Any CPU.Build.0 = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x64.ActiveCfg = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x64.Build.0 = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x86.ActiveCfg = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x86.Build.0 = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x64.Build.0 = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x86.Build.0 = Debug|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x64.ActiveCfg = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x64.Build.0 = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x86.ActiveCfg = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x86.Build.0 = Release|Any CPU
 		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x64.Build.0 = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x86.Build.0 = Debug|Any CPU
 		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x64.ActiveCfg = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x64.Build.0 = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x86.ActiveCfg = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x86.Build.0 = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x64.Build.0 = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x86.Build.0 = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|Any CPU.Build.0 = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x64.ActiveCfg = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x64.Build.0 = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x86.ActiveCfg = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x86.Build.0 = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x64.Build.0 = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x86.Build.0 = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x64.ActiveCfg = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x64.Build.0 = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x86.ActiveCfg = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x86.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
 	EndGlobalSection
 EndGlobal

+ 1 - 1
Shared.Rcl/Components/GlobalSearch.razor

@@ -7,7 +7,7 @@
 @using Microsoft.AspNetCore.Components.Web
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Resources
-@using Shared.Rcl.Services
+@using RackPeek.Domain.Search
 
 @inject IResourceCollection Repo
 @inject NavigationManager Nav

+ 1 - 0
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -11,5 +11,6 @@
   "ssh-config-export.md",
   "hosts-file-export.md",
   "inventory-api.md",
+  "mcp-guide.md",
   "versioning.md"
 ]

+ 117 - 0
Shared.Rcl/wwwroot/raw_docs/mcp-guide.md

@@ -0,0 +1,117 @@
+# MCP Server Guide
+
+RackPeek ships a built-in [Model Context Protocol](https://modelcontextprotocol.io) server, so
+AI assistants (Claude Code, Claude Desktop, Cursor, VS Code, and anything else that speaks
+MCP) can query, manage, maintain and build your inventory for you.
+
+There is nothing extra to run: whenever the RackPeek web server is up, the MCP server is
+listening at **`/mcp`** over streamable HTTP. It uses the same `X-Api-Key` gate as the
+[Inventory API](/docs/inventory-api) — until you set `RPK_API_KEY` on the server the
+endpoint answers `503` and stays shut.
+
+---
+
+## Quick start
+
+```bash
+# Run the server with an API key
+docker run -d -p 8080:8080 \
+  -v ./config:/config \
+  -e RPK_API_KEY=your-shared-secret \
+  aptacode/rackpeek:latest
+
+# Connect Claude Code to it
+claude mcp add --transport http rackpeek http://rack.lan:8080/mcp \
+  --header "X-Api-Key: your-shared-secret"
+```
+
+Then just ask: *"what's running on my proxmox nodes?"*, *"add my new switch and cable it to
+the rack server"*, *"generate an ssh config for everything tagged prod"*.
+
+For clients that only speak stdio, put a stdio→HTTP proxy such as
+[`mcp-remote`](https://www.npmjs.com/package/mcp-remote) in front of the same URL.
+
+---
+
+## The tools
+
+### Query
+
+| Tool | What it answers |
+|---|---|
+| `list_resources` | every resource, with optional `kind` / `tag` / `labelKey` filters |
+| `get_resource` | one resource in full, as YAML that can be edited and upserted back |
+| `search_resources` | free-text search over names, IPs, tags and labels |
+| `get_summary` | counts of everything: hardware by kind, systems by type/OS, services, tags, labels |
+| `get_tree` | the containment forest: hardware → systems → services |
+| `list_connections` | physical port-to-port cabling |
+| `get_subnets` | service IPs grouped into subnets, or filtered by a CIDR block |
+| `get_schema` | the JSON schema and authoring rules for inventory YAML |
+
+### Editing
+
+| Tool | What it does |
+|---|---|
+| `upsert_resources` | bulk create/update from a YAML document — the main write path, with `dryRun` returning a per-resource diff before anything is written |
+| `delete_resource` | removes a resource, detaches dependants, unplugs its connections |
+| `rename_resource` | renames and rewrites every `runsOn` link and connection endpoint |
+| `clone_resource` | copies a resource under a new name (never the discovery id) |
+| `edit_tags` / `edit_labels` | add/remove tags and labels — merge mode can't remove, these can |
+| `add_connection` / `remove_connection` | plug and unplug ports |
+
+### Exporters
+
+`export_ansible_inventory`, `export_ssh_config`, `export_hosts_file` and
+`export_topology_mermaid` render the same outputs as the CLI exporters, straight into the
+conversation.
+
+### Git
+
+`git_status` and `git_commit` version the config directory. They activate exactly like the
+web UI's git integration — start the server with `GIT_TOKEN` (and optionally
+`GIT_USERNAME`) — and explain that when they are off. See
+[Git integration](/docs/git-integration).
+
+### Discovery
+
+| Tool | Reads |
+|---|---|
+| `discover_docker` | a Docker/Podman engine (`dockerHost`, e.g. `tcp://nas01:2375`) |
+| `discover_proxmox` | a Proxmox VE cluster (`host`, e.g. `https://pve.lan:8006`) |
+
+Both default to a **preview**: they return the discovered YAML for review and write
+nothing. Pass `apply: true` to merge the result into the inventory — discovery merging
+can add and update but never removes, and re-runs line resources up by
+[discovery id](/docs/discovery-guide) so your renames stick.
+
+Proxmox credentials come from the server's own `RPK_PVE_TOKEN_ID` /
+`RPK_PVE_TOKEN_SECRET` configuration, never from the conversation, so tokens stay out of
+AI context windows. There is no `discover system` tool on purpose: it probes the machine
+it runs on, which for the server is its own container — run `rpk discover system` on the
+machine being inventoried instead.
+
+---
+
+## The editing workflow an agent follows
+
+1. `get_schema` — learn the document format once.
+2. `get_resource` / `list_resources` — read the current state.
+3. `upsert_resources` with `dryRun: true` — preview the exact per-resource diff.
+4. `upsert_resources` — apply.
+5. `git_commit` — snapshot the change (when git is configured).
+
+Merge mode only adds and updates; anything destructive (deleting resources, removing
+tags/labels/connections) goes through the dedicated tools, which are annotated as
+destructive so well-behaved clients ask before calling them.
+
+---
+
+## Security notes
+
+- The MCP endpoint is **off until `RPK_API_KEY` is set** — same behaviour as the
+  inventory API.
+- Anyone holding the key can read *and modify* the inventory through MCP. Treat the key
+  accordingly, and put the server behind TLS (a reverse proxy) before exposing it beyond
+  your LAN.
+- The transport is stateless HTTP: no sessions, no sticky-session requirements behind a
+  reverse proxy.

+ 182 - 0
Tests.E2e/McpE2eTests.cs

@@ -0,0 +1,182 @@
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     The full production path: the shipped Docker image, a real network port, and a
+///     real MCP client walking one realistic session — learn the schema, preview a
+///     change, apply it, query it back, wire a connection, export, delete. Everything
+///     the in-process suite (Tests.Mcp) proves is re-proved here against the artefact
+///     that is actually distributed.
+/// </summary>
+public class McpE2eTests : IAsyncLifetime {
+    private const string _dockerImage = "rackpeek:ci";
+    private const string _apiKey = "e2e-mcp-key";
+
+    private IContainer _container = default!;
+    private HttpClient _http = default!;
+    private McpClient _client = default!;
+
+    public async Task InitializeAsync() {
+        _container = new ContainerBuilder(_dockerImage)
+            .WithPortBinding(8080, true)
+            .WithEnvironment("RPK_API_KEY", _apiKey)
+            .WithWaitStrategy(
+                Wait.ForUnixContainer()
+                    .UntilHttpRequestIsSucceeded(r => r
+                        .ForPort(8080)
+                        .ForPath("/health")))
+            .Build();
+
+        await _container.StartAsync();
+
+        _http = new HttpClient();
+        _http.DefaultRequestHeaders.Add("X-Api-Key", _apiKey);
+
+        var transport = new HttpClientTransport(
+            new HttpClientTransportOptions {
+                Endpoint = new Uri($"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/mcp"),
+                TransportMode = HttpTransportMode.StreamableHttp
+            },
+            _http,
+            loggerFactory: null,
+            ownsHttpClient: true);
+
+        _client = await McpClient.CreateAsync(transport);
+    }
+
+    public async Task DisposeAsync() {
+        if (_client != null) await _client.DisposeAsync();
+        if (_container != null) await _container.DisposeAsync();
+    }
+
+    private async Task<JsonElement> CallAsync(string tool, Dictionary<string, object?>? args = null) {
+        CallToolResult result = await _client.CallToolAsync(tool, args);
+
+        var text = string.Join("\n", result.Content.OfType<TextContentBlock>().Select(t => t.Text));
+        Assert.False(result.IsError == true, $"'{tool}' failed: {text}");
+
+        return result.StructuredContent
+               ?? JsonDocument.Parse(JsonSerializer.Serialize(new { text })).RootElement;
+    }
+
+    [Fact]
+    public async Task A_full_session_builds_queries_wires_exports_and_deletes_a_stack() {
+        // The server advertises itself and its whole tool surface over the wire.
+        Assert.Equal("rackpeek", _client.ServerInfo.Name);
+        IList<McpClientTool> tools = await _client.ListToolsAsync();
+        Assert.Contains(tools, t => t.Name == "upsert_resources");
+        Assert.Contains(tools, t => t.Name == "discover_docker");
+
+        // 1. Learn the format.
+        JsonElement schema = await CallAsync("get_schema");
+        Assert.Equal(4, schema.GetProperty("version").GetInt32());
+
+        // 2. Preview, then apply, a small stack.
+        const string stack =
+            """
+            version: 4
+            resources:
+              - kind: Server
+                name: e2e-server
+                ports:
+                  - type: rj45
+                    speed: 1
+                    count: 4
+              - kind: Switch
+                name: e2e-switch
+                ports:
+                  - type: rj45
+                    speed: 1
+                    count: 8
+              - kind: System
+                name: e2e-host
+                type: baremetal
+                os: debian
+                ip: 10.9.0.1
+                runsOn:
+                  - e2e-server
+              - kind: Service
+                name: e2e-app
+                runsOn:
+                  - e2e-host
+                network:
+                  ip: 10.9.0.1
+                  port: 8080
+                  protocol: TCP
+            """;
+
+        JsonElement preview = await CallAsync("upsert_resources",
+            new Dictionary<string, object?> { ["yaml"] = stack, ["dryRun"] = true });
+        Assert.Equal(4, preview.GetProperty("added").GetArrayLength());
+
+        JsonElement applied = await CallAsync("upsert_resources",
+            new Dictionary<string, object?> { ["yaml"] = stack });
+        Assert.Equal(4, applied.GetProperty("added").GetArrayLength());
+
+        // 3. Query it back over the wire.
+        JsonElement list = await CallAsync("list_resources",
+            new Dictionary<string, object?> { ["kind"] = "Service" });
+        Assert.Equal(1, list.GetProperty("count").GetInt32());
+
+        JsonElement detail = await CallAsync("get_resource",
+            new Dictionary<string, object?> { ["name"] = "e2e-app" });
+        Assert.Contains("kind: Service", detail.GetProperty("yaml").GetString());
+
+        JsonElement search = await CallAsync("search_resources",
+            new Dictionary<string, object?> { ["query"] = "10.9.0.1" });
+        Assert.True(search.GetProperty("matches").GetArrayLength() >= 1);
+
+        // 4. Wire the hardware together and see it in the tree and the diagram.
+        await CallAsync("add_connection", new Dictionary<string, object?> {
+            ["resourceA"] = "e2e-server",
+            ["portGroupA"] = 0,
+            ["portIndexA"] = 0,
+            ["resourceB"] = "e2e-switch",
+            ["portGroupB"] = 0,
+            ["portIndexB"] = 0,
+            ["label"] = "uplink"
+        });
+
+        JsonElement tree = await CallAsync("get_tree",
+            new Dictionary<string, object?> { ["hardwareName"] = "e2e-server" });
+        Assert.Equal(1, tree.GetProperty("hardware").GetArrayLength());
+
+        JsonElement mermaid = await CallAsync("export_topology_mermaid");
+        Assert.Contains("e2e-switch", mermaid.GetProperty("text").GetString());
+
+        // 5. The change shows up on the page a browser would load, not just over MCP.
+        var home = await _http.GetStringAsync(
+            $"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/health");
+        Assert.Equal("rackpeek", home);
+
+        // 6. Tear the service down again and the inventory agrees.
+        await CallAsync("delete_resource", new Dictionary<string, object?> { ["name"] = "e2e-app" });
+
+        JsonElement after = await CallAsync("list_resources",
+            new Dictionary<string, object?> { ["kind"] = "Service" });
+        Assert.Equal(0, after.GetProperty("count").GetInt32());
+    }
+
+    [Fact]
+    public async Task The_gate_holds_over_a_real_network_socket() {
+        using var bare = new HttpClient();
+
+        var url = $"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/mcp";
+        using var request = new HttpRequestMessage(HttpMethod.Post, url) {
+            Content = new StringContent(
+                """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""",
+                System.Text.Encoding.UTF8,
+                "application/json")
+        };
+        request.Headers.Add("Accept", "application/json, text/event-stream");
+
+        using HttpResponseMessage response = await bare.SendAsync(request);
+
+        Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+}

+ 6 - 5
Tests.E2e/Tests.E2e.csproj

@@ -12,12 +12,13 @@
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
             <PrivateAssets>all</PrivateAssets>
         </PackageReference>
-        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
         <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
         <PackageReference Include="Microsoft.Playwright" Version="1.62.0" />
         <PackageReference Include="Microsoft.Playwright.Xunit" Version="1.62.0" />
+        <PackageReference Include="ModelContextProtocol" Version="2.2.0" />
         <PackageReference Include="Testcontainers" Version="4.15.0" />
-        <PackageReference Include="xunit" Version="2.9.3"/>
+        <PackageReference Include="xunit" Version="2.9.3" />
         <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
             <PrivateAssets>all</PrivateAssets>
@@ -25,7 +26,7 @@
     </ItemGroup>
 
     <ItemGroup>
-        <Using Include="Xunit"/>
+        <Using Include="Xunit" />
     </ItemGroup>
 
     <ItemGroup>
@@ -35,8 +36,8 @@
     </ItemGroup>
 
     <ItemGroup>
-        <ProjectReference Include="..\RackPeek.Web.Viewer\RackPeek.Web.Viewer.csproj"/>
-        <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj"/>
+        <ProjectReference Include="..\RackPeek.Web.Viewer\RackPeek.Web.Viewer.csproj" />
+        <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj" />
     </ItemGroup>
 
 </Project>

+ 78 - 0
Tests.Mcp/AuthTests.cs

@@ -0,0 +1,78 @@
+using System.Net;
+using System.Text;
+using ModelContextProtocol.Client;
+using RackPeek.Mcp.Tools;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     /mcp sits behind the same X-Api-Key gate as /api/inventory: 503 until the
+///     server has a key configured (MCP is off by default), 401 on a wrong key.
+///     The raw-HTTP tests pin the status codes; the client-level tests prove the
+///     gate actually stops an MCP session, not just a request.
+/// </summary>
+public class AuthTests {
+    private const string _initializeBody =
+        """
+        {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
+        """;
+
+    private static HttpRequestMessage InitializeRequest() {
+        var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") {
+            Content = new StringContent(_initializeBody, Encoding.UTF8, "application/json")
+        };
+
+        request.Headers.Add("Accept", "application/json, text/event-stream");
+
+        return request;
+    }
+
+    [Fact]
+    public async Task Without_a_configured_key_the_endpoint_answers_503_and_stays_shut() {
+        using var api = new McpFixture(extraConfig: new Dictionary<string, string?> {
+            ["RPK_API_KEY"] = null
+        });
+
+        using HttpClient client = api.CreateHttpClient(apiKey: null);
+        using HttpResponseMessage response = await client.SendAsync(InitializeRequest());
+
+        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task A_missing_key_is_rejected_with_401() {
+        using var api = new McpFixture();
+
+        using HttpClient client = api.CreateHttpClient(apiKey: null);
+        using HttpResponseMessage response = await client.SendAsync(InitializeRequest());
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task A_wrong_key_is_rejected_with_401() {
+        using var api = new McpFixture();
+
+        using HttpClient client = api.CreateHttpClient("not-the-key");
+        using HttpResponseMessage response = await client.SendAsync(InitializeRequest());
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task An_mcp_client_without_the_key_cannot_even_finish_the_handshake() {
+        using var api = new McpFixture();
+
+        await Assert.ThrowsAnyAsync<Exception>(() => api.ConnectAsync(apiKey: null));
+    }
+
+    [Fact]
+    public async Task The_right_key_opens_the_full_tool_surface() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        ResourceList resources = await client.CallOkAsync<ResourceList>("list_resources");
+
+        Assert.Equal(0, resources.Count);
+    }
+}

+ 196 - 0
Tests.Mcp/DiscoveryToolTests.cs

@@ -0,0 +1,196 @@
+using ModelContextProtocol.Client;
+using RackPeek.Mcp.Tools;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     The discovery tools against fake engines answering on real sockets: preview
+///     returns reviewable YAML, apply merges it, and a second run changes nothing —
+///     the discovery-id identity contract, observed through the MCP surface.
+/// </summary>
+public class DiscoveryToolTests {
+    // -- discover_docker ----------------------------------------------------------------
+
+    [Fact]
+    public async Task Docker_discovery_previews_reachable_containers_as_conformant_yaml() {
+        await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync();
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        DiscoveryResult result = await client.CallOkAsync<DiscoveryResult>(
+            "discover_docker", new Dictionary<string, object?> {
+                ["dockerHost"] = $"tcp://{engine.Host}",
+                ["hostName"] = "nas01"
+            });
+
+        // The fixture holds six containers; redis publishes nothing and pgadmin only
+        // binds loopback, so four become services.
+        Assert.Equal(4, result.ResourceCount);
+        Assert.Equal(2, result.Skipped);
+        Assert.Null(result.Applied);
+
+        Assert.Contains("jellyfin", result.Yaml);
+        Assert.Contains("wireguard", result.Yaml);
+        Assert.DoesNotContain("redis", result.Yaml);
+        Assert.Contains("runsOn", result.Yaml);
+        Assert.Contains("nas01", result.Yaml);
+        SchemaAssert.ConformsToSchema(result.Yaml);
+
+        // Preview writes nothing.
+        Assert.DoesNotContain("jellyfin", api.StoredYaml);
+    }
+
+    [Fact]
+    public async Task Applying_docker_discovery_merges_and_a_second_run_changes_nothing() {
+        await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync();
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        var args = new Dictionary<string, object?> {
+            ["dockerHost"] = $"tcp://{engine.Host}",
+            ["hostName"] = "nas01",
+            ["apply"] = true
+        };
+
+        DiscoveryResult first = await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
+
+        Assert.NotNull(first.Applied);
+        Assert.Equal(4, first.Applied.Added.Count);
+        Assert.Contains("jellyfin", api.StoredYaml);
+        Assert.Contains("discoveryId: rpk1:docker:", api.StoredYaml);
+        SchemaAssert.ConformsToSchema(api.StoredYaml);
+
+        // Same engine, same answers: the ids line everything up, nothing duplicates.
+        DiscoveryResult second = await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
+
+        Assert.NotNull(second.Applied);
+        Assert.Empty(second.Applied.Added);
+        Assert.Empty(second.Applied.Updated);
+    }
+
+    [Fact]
+    public async Task Discovered_services_survive_a_user_rename_on_the_next_apply() {
+        // The identity contract, end to end: the user renames a discovered service,
+        // discovery runs again, and the rename sticks because the id matches.
+        await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync();
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        var args = new Dictionary<string, object?> {
+            ["dockerHost"] = $"tcp://{engine.Host}",
+            ["hostName"] = "nas01",
+            ["apply"] = true
+        };
+
+        await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
+
+        await client.CallTextAsync("rename_resource", new Dictionary<string, object?> {
+            ["name"] = "jellyfin",
+            ["newName"] = "media-jellyfin"
+        });
+
+        DiscoveryResult again = await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
+
+        Assert.NotNull(again.Applied);
+        Assert.Empty(again.Applied.Added); // not re-added under the old name
+        Assert.Contains("media-jellyfin", api.StoredYaml);
+    }
+
+    [Fact]
+    public async Task An_unreachable_docker_engine_is_a_clean_error_naming_the_endpoint() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        var error = await client.CallErrorAsync("discover_docker",
+            new Dictionary<string, object?> { ["dockerHost"] = "tcp://127.0.0.1:1" });
+
+        Assert.Contains("Could not reach Docker", error);
+        Assert.Contains("tcp://127.0.0.1:1", error);
+    }
+
+    [Fact]
+    public async Task A_malformed_docker_endpoint_is_rejected_before_any_io() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        var error = await client.CallErrorAsync("discover_docker",
+            new Dictionary<string, object?> { ["dockerHost"] = "%%not-an-endpoint%%" });
+
+        Assert.Contains("not a usable Docker endpoint", error);
+    }
+
+    // -- discover_proxmox ---------------------------------------------------------------
+
+    private static Dictionary<string, string?> PveCredentials() => new() {
+        ["RPK_PVE_TOKEN_ID"] = "root@pam!rackpeek",
+        ["RPK_PVE_TOKEN_SECRET"] = "secret-uuid"
+    };
+
+    [Fact]
+    public async Task Proxmox_discovery_previews_nodes_and_guests_wired_together() {
+        await using FakeHttpServer pve = await FakeHttpServer.StartProxmoxAsync();
+        using var api = new McpFixture(extraConfig: PveCredentials());
+        await using McpClient client = await api.ConnectAsync();
+
+        DiscoveryResult result = await client.CallOkAsync<DiscoveryResult>(
+            "discover_proxmox", new Dictionary<string, object?> { ["host"] = pve.BaseUrl });
+
+        Assert.Null(result.Applied);
+        SchemaAssert.ConformsToSchema(result.Yaml);
+
+        // Two fixture nodes, each a Server plus its hypervisor System...
+        Assert.Contains("pve01", result.Yaml);
+        Assert.Contains("pve02", result.Yaml);
+        Assert.Contains("kind: Server", result.Yaml);
+        Assert.Contains("hypervisor", result.Yaml);
+
+        // ...and guests parented onto them, deduped by vmid even though both fake
+        // nodes reported the same guest lists (the in-flight migration case).
+        Assert.Contains("docker-01", result.Yaml);
+        Assert.Contains("pihole", result.Yaml);
+        Assert.Single(
+            result.Yaml.Split(Environment.NewLine),
+            l => l.Contains("name: pihole"));
+    }
+
+    [Fact]
+    public async Task Applying_proxmox_discovery_persists_the_estate() {
+        await using FakeHttpServer pve = await FakeHttpServer.StartProxmoxAsync();
+        using var api = new McpFixture(extraConfig: PveCredentials());
+        await using McpClient client = await api.ConnectAsync();
+
+        DiscoveryResult result = await client.CallOkAsync<DiscoveryResult>(
+            "discover_proxmox",
+            new Dictionary<string, object?> { ["host"] = pve.BaseUrl, ["apply"] = true });
+
+        Assert.NotNull(result.Applied);
+        Assert.NotEmpty(result.Applied.Added);
+        Assert.Contains("pve01", api.StoredYaml);
+        Assert.Contains("discoveryId: rpk1:pve:", api.StoredYaml);
+        SchemaAssert.ConformsToSchema(api.StoredYaml);
+    }
+
+    [Fact]
+    public async Task Missing_proxmox_credentials_point_at_the_exact_settings_to_set() {
+        using var api = new McpFixture(); // no RPK_PVE_* configured
+        await using McpClient client = await api.ConnectAsync();
+
+        var error = await client.CallErrorAsync("discover_proxmox",
+            new Dictionary<string, object?> { ["host"] = "https://pve.lan:8006" });
+
+        Assert.Contains("RPK_PVE_TOKEN_ID", error);
+        Assert.Contains("RPK_PVE_TOKEN_SECRET", error);
+    }
+
+    [Fact]
+    public async Task An_unreachable_proxmox_host_is_a_clean_error_naming_the_endpoint() {
+        using var api = new McpFixture(extraConfig: PveCredentials());
+        await using McpClient client = await api.ConnectAsync();
+
+        var error = await client.CallErrorAsync("discover_proxmox",
+            new Dictionary<string, object?> { ["host"] = "http://127.0.0.1:1" });
+
+        Assert.Contains("Could not read", error);
+        Assert.Contains("http://127.0.0.1:1", error);
+    }
+}

+ 102 - 0
Tests.Mcp/ExportToolTests.cs

@@ -0,0 +1,102 @@
+using ModelContextProtocol.Client;
+using RackPeek.Domain.UseCases.Ansible;
+using RackPeek.Domain.UseCases.Hosts;
+using RackPeek.Domain.UseCases.SSH;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     The exporters render the same seed everywhere, so these tests check the shape a
+///     downstream consumer (ansible, ssh, /etc/hosts, mermaid) would actually parse.
+/// </summary>
+public class ExportToolTests {
+    [Fact]
+    public async Task The_ansible_inventory_groups_labelled_hosts_in_both_formats() {
+        // The generator addresses hosts by their ansible_host/ip/hostname label and
+        // emits them under the groups asked for — no grouping, no output.
+        using var api = new McpFixture(TestData.Seed);
+        await using McpClient client = await api.ConnectAsync();
+
+        var args = new Dictionary<string, object?> { ["groupByTags"] = new[] { "prod" } };
+
+        InventoryResult ini = await client.CallOkAsync<InventoryResult>("export_ansible_inventory", args);
+        InventoryResult yaml = await client.CallOkAsync<InventoryResult>(
+            "export_ansible_inventory",
+            new Dictionary<string, object?>(args) { ["format"] = "Yaml" });
+
+        Assert.Contains("[prod]", ini.InventoryText);
+        Assert.Contains("rack-server", ini.InventoryText);
+        Assert.Contains("ansible_host=10.0.0.2", ini.InventoryText);
+        Assert.Contains("rack-server", yaml.InventoryText);
+        Assert.NotEqual(ini.InventoryText, yaml.InventoryText);
+    }
+
+    [Fact]
+    public async Task The_ssh_config_writes_host_blocks_with_the_chosen_defaults() {
+        using var api = new McpFixture(TestData.Seed);
+        await using McpClient client = await api.ConnectAsync();
+
+        SshExportResult result = await client.CallOkAsync<SshExportResult>(
+            "export_ssh_config", new Dictionary<string, object?> { ["defaultUser"] = "admin" });
+
+        Assert.Contains("Host host-os", result.ConfigText);
+        Assert.Contains("HostName 10.0.0.5", result.ConfigText);
+        Assert.Contains("User admin", result.ConfigText);
+    }
+
+    [Fact]
+    public async Task The_hosts_file_maps_addresses_to_names_with_an_optional_suffix() {
+        using var api = new McpFixture(TestData.Seed);
+        await using McpClient client = await api.ConnectAsync();
+
+        HostsExportResult plain = await client.CallOkAsync<HostsExportResult>("export_hosts_file");
+        HostsExportResult suffixed = await client.CallOkAsync<HostsExportResult>(
+            "export_hosts_file", new Dictionary<string, object?> {
+                ["domainSuffix"] = "home.lan",
+                ["includeLocalhostDefaults"] = false
+            });
+
+        Assert.Contains("127.0.0.1 localhost", plain.HostsText);
+        Assert.Contains("10.0.0.5 host-os", plain.HostsText);
+        Assert.Contains("10.0.0.5 host-os.home.lan", suffixed.HostsText);
+        Assert.DoesNotContain("localhost", suffixed.HostsText);
+    }
+
+    [Fact]
+    public async Task The_mermaid_views_draw_the_physical_and_logical_pictures() {
+        using var api = new McpFixture(TestData.Seed);
+        await using McpClient client = await api.ConnectAsync();
+
+        var physical = await client.CallTextAsync("export_topology_mermaid");
+        var logical = await client.CallTextAsync(
+            "export_topology_mermaid", new Dictionary<string, object?> { ["view"] = "Logical" });
+
+        // Physical: hardware nodes and the cabled edge between them.
+        Assert.Contains("rack-server", physical);
+        Assert.Contains("rack-switch", physical);
+        Assert.Contains("uplink", physical);
+
+        // Logical: host cards carrying their services; no cabling.
+        Assert.Contains("host-os", logical);
+        Assert.Contains("grafana", logical);
+        Assert.DoesNotContain("uplink", logical);
+    }
+
+    [Fact]
+    public async Task Exports_over_the_demo_inventory_produce_output_for_every_format() {
+        using var api = new McpFixture(TestData.DemoConfig());
+        await using McpClient client = await api.ConnectAsync();
+
+        InventoryResult ansible = await client.CallOkAsync<InventoryResult>("export_ansible_inventory");
+        SshExportResult ssh = await client.CallOkAsync<SshExportResult>("export_ssh_config");
+        HostsExportResult hosts = await client.CallOkAsync<HostsExportResult>("export_hosts_file");
+        var mermaid = await client.CallTextAsync("export_topology_mermaid");
+
+        // No grouping asked for, so ansible legitimately answers with a warning
+        // rather than hosts; the call itself must still succeed.
+        Assert.NotNull(ansible);
+        Assert.False(string.IsNullOrWhiteSpace(ssh.ConfigText));
+        Assert.Contains("pfsense-fw", mermaid);
+        Assert.NotEmpty(hosts.HostsText);
+    }
+}

+ 65 - 0
Tests.Mcp/FakeHttpServer.cs

@@ -0,0 +1,65 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     A real Kestrel server on a random loopback port, serving captured API fixtures.
+///     The discovery tools construct their own HttpClients internally, so unlike the
+///     unit tests in Tests.Discovery a message-handler stub cannot reach them — the
+///     fake engine has to answer on an actual socket.
+/// </summary>
+internal sealed class FakeHttpServer : IAsyncDisposable {
+    private readonly WebApplication _app;
+
+    private FakeHttpServer(WebApplication app) => _app = app;
+
+    /// <summary>e.g. http://127.0.0.1:49213 — no trailing slash.</summary>
+    public string BaseUrl => _app.Urls.First();
+
+    public string Host => new Uri(BaseUrl).Authority;
+
+    public static async Task<FakeHttpServer> StartAsync(Action<WebApplication> map) {
+        WebApplicationBuilder builder = WebApplication.CreateBuilder();
+        builder.Logging.ClearProviders();
+        builder.WebHost.UseUrls("http://127.0.0.1:0");
+
+        WebApplication app = builder.Build();
+        map(app);
+        await app.StartAsync();
+
+        return new FakeHttpServer(app);
+    }
+
+    /// <summary>A fake Docker Engine API with the shared captured fixtures.</summary>
+    public static Task<FakeHttpServer> StartDockerEngineAsync() =>
+        StartAsync(app => {
+            app.MapGet("/containers/json", () => Results.Content(
+                TestData.Fixture("docker-containers.json"), "application/json"));
+            app.MapGet("/info", () => Results.Content(
+                TestData.Fixture("docker-info.json"), "application/json"));
+        });
+
+    /// <summary>
+    ///     A fake Proxmox VE API. Both fixture nodes answer with the same guest lists,
+    ///     which doubles as the migration case: the mapper must dedupe guests by vmid.
+    /// </summary>
+    public static Task<FakeHttpServer> StartProxmoxAsync() =>
+        StartAsync(app => {
+            string Json(string name) => TestData.Fixture(name);
+
+            app.MapGet("/api2/json/cluster/status", () => Results.Content(Json("pve-cluster-status.json"), "application/json"));
+            app.MapGet("/api2/json/nodes", () => Results.Content(Json("pve-nodes-full.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/status", () => Results.Content(Json("pve-node-status.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/disks/list", () => Results.Content(Json("pve-disks.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/hardware/pci", () => Results.Content(Json("pve-hardware-pci.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/qemu", () => Results.Content(Json("pve-qemu.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/lxc", () => Results.Content(Json("pve-lxc.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/qemu/{vmid}/config", () => Results.Content(Json("pve-qemu-config.json"), "application/json"));
+            app.MapGet("/api2/json/nodes/{node}/lxc/{vmid}/config", () => Results.Content(Json("pve-lxc-config.json"), "application/json"));
+        });
+
+    public async ValueTask DisposeAsync() => await _app.DisposeAsync();
+}

+ 104 - 0
Tests.Mcp/GitToolTests.cs

@@ -0,0 +1,104 @@
+using ModelContextProtocol.Client;
+using RackPeek.Mcp.Tools;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     Git tools ride on the same GIT_TOKEN opt-in as the web UI: without it they say
+///     how to turn git on; with it the config directory is a real repository (the
+///     server auto-inits it) and commits are observable through git_status.
+/// </summary>
+public class GitToolTests {
+    private static Dictionary<string, string?> WithGit() => new() {
+        ["GIT_TOKEN"] = "dummy-token-for-local-repo"
+    };
+
+    [Fact]
+    public async Task Without_a_token_the_tools_explain_how_to_enable_git() {
+        using var api = new McpFixture(TestData.Seed);
+        await using McpClient client = await api.ConnectAsync();
+
+        GitStatusResult status = await client.CallOkAsync<GitStatusResult>("git_status");
+
+        Assert.False(status.Available);
+        Assert.Contains("GIT_TOKEN", status.Message);
+
+        var error = await client.CallErrorAsync(
+            "git_commit", new Dictionary<string, object?> { ["message"] = "won't happen" });
+        Assert.Contains("GIT_TOKEN", error);
+    }
+
+    [Fact]
+    public async Task A_fresh_config_repo_reports_dirty_then_commits_clean() {
+        using var api = new McpFixture(TestData.Seed, WithGit());
+        await using McpClient client = await api.ConnectAsync();
+
+        GitStatusResult before = await client.CallOkAsync<GitStatusResult>("git_status");
+        Assert.True(before.Available);
+        Assert.Equal("Dirty", before.Status); // config.yaml is untracked
+        Assert.False(before.HasRemote);
+
+        var committed = await client.CallTextAsync(
+            "git_commit", new Dictionary<string, object?> { ["message"] = "inventory snapshot" });
+        Assert.Equal("Committed.", committed);
+
+        GitStatusResult after = await client.CallOkAsync<GitStatusResult>("git_status");
+        Assert.Equal("Clean", after.Status);
+        Assert.NotNull(after.RecentCommits);
+        Assert.Contains(after.RecentCommits, c => c.Contains("inventory snapshot"));
+    }
+
+    [Fact]
+    public async Task An_mcp_edit_shows_up_as_a_dirty_tree_ready_to_commit() {
+        using var api = new McpFixture(TestData.Seed, WithGit());
+        await using McpClient client = await api.ConnectAsync();
+
+        await client.CallTextAsync(
+            "git_commit", new Dictionary<string, object?> { ["message"] = "baseline" });
+
+        await client.CallOkAsync<TagsResult>("edit_tags", new Dictionary<string, object?> {
+            ["name"] = "rack-server",
+            ["add"] = new[] { "audited" }
+        });
+
+        GitStatusResult status = await client.CallOkAsync<GitStatusResult>("git_status");
+        Assert.Equal("Dirty", status.Status);
+        Assert.NotNull(status.ChangedFiles);
+        Assert.Contains(status.ChangedFiles, f => f.Contains("config.yaml"));
+    }
+
+    [Fact]
+    public async Task Committing_a_clean_tree_succeeds_without_inventing_a_commit() {
+        using var api = new McpFixture(TestData.Seed, WithGit());
+        await using McpClient client = await api.ConnectAsync();
+
+        await client.CallTextAsync(
+            "git_commit", new Dictionary<string, object?> { ["message"] = "first" });
+        var second = await client.CallTextAsync(
+            "git_commit", new Dictionary<string, object?> { ["message"] = "second" });
+
+        Assert.Equal("Committed.", second);
+
+        GitStatusResult status = await client.CallOkAsync<GitStatusResult>("git_status");
+        Assert.NotNull(status.RecentCommits);
+        var commit = Assert.Single(status.RecentCommits);
+        Assert.Contains("first", commit);
+    }
+
+    [Fact]
+    public async Task Pushing_without_a_remote_is_an_error_that_says_the_commit_happened() {
+        using var api = new McpFixture(TestData.Seed, WithGit());
+        await using McpClient client = await api.ConnectAsync();
+
+        var error = await client.CallErrorAsync("git_commit", new Dictionary<string, object?> {
+            ["message"] = "local only",
+            ["push"] = true
+        });
+
+        Assert.Contains("Committed, but the push failed", error);
+        Assert.Contains("No remote", error);
+
+        GitStatusResult status = await client.CallOkAsync<GitStatusResult>("git_status");
+        Assert.Equal("Clean", status.Status); // the commit itself landed
+    }
+}

+ 102 - 0
Tests.Mcp/McpFixture.cs

@@ -0,0 +1,102 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using ModelContextProtocol.Client;
+using RackPeek.Web;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     A real RackPeek server backed by a temporary config file, driven through a real
+///     MCP client speaking streamable-HTTP JSON-RPC to /mcp. Every test in this project
+///     goes end to end through this: if a behaviour is not observable here, an MCP
+///     client cannot observe it either.
+/// </summary>
+public sealed class McpFixture : IDisposable {
+    public const string ApiKey = "mcp-test-key";
+
+    private readonly WebApplicationFactory<Program> _factory;
+
+    /// <param name="initialConfig">
+    ///     Contents to seed config.yaml with before the server first reads it.
+    /// </param>
+    /// <param name="extraConfig">
+    ///     Extra configuration for the server — e.g. GIT_TOKEN to activate the git
+    ///     tools, or RPK_API_KEY = null to model a server with no key configured.
+    /// </param>
+    public McpFixture(string? initialConfig = null, IDictionary<string, string?>? extraConfig = null) {
+        TempDir = Path.Combine(Path.GetTempPath(), "rackpeek-mcp-tests", Guid.NewGuid().ToString());
+        Directory.CreateDirectory(TempDir);
+
+        if (initialConfig != null)
+            File.WriteAllText(ConfigPath, initialConfig);
+
+        _factory = new WebApplicationFactory<Program>()
+            .WithWebHostBuilder(builder => {
+                builder.UseSetting("RPK_YAML_DIR", TempDir);
+
+                // Settings read during startup (GIT_TOKEN wires services in BuildApp)
+                // must go through UseSetting: the in-memory collection below lands too
+                // late for service registration, though request-time reads see it fine.
+                foreach ((var key, var value) in extraConfig ?? new Dictionary<string, string?>())
+                    if (value != null)
+                        builder.UseSetting(key, value);
+
+                builder.ConfigureAppConfiguration((_, config) => {
+                    var values = new Dictionary<string, string?> {
+                        ["RPK_YAML_DIR"] = TempDir,
+                        ["RPK_API_KEY"] = ApiKey
+                    };
+
+                    foreach ((var key, var value) in extraConfig ?? new Dictionary<string, string?>())
+                        values[key] = value;
+
+                    config.AddInMemoryCollection(values);
+                });
+            });
+    }
+
+    public string TempDir { get; }
+
+    public string ConfigPath => Path.Combine(TempDir, "config.yaml");
+
+    /// <summary>What is actually on disk — the ground truth every mutation test asserts on.</summary>
+    public string StoredYaml => File.ReadAllText(ConfigPath);
+
+    /// <summary>An HTTP client for driving /mcp (or anything else) below the MCP layer.</summary>
+    public HttpClient CreateHttpClient(string? apiKey = ApiKey) {
+        HttpClient client = _factory.CreateClient();
+
+        if (apiKey != null)
+            client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
+
+        return client;
+    }
+
+    /// <summary>A connected MCP client; the initialize handshake has already succeeded.</summary>
+    public async Task<McpClient> ConnectAsync(string? apiKey = ApiKey) {
+        HttpClient http = CreateHttpClient(apiKey);
+
+        var transport = new HttpClientTransport(
+            new HttpClientTransportOptions {
+                Endpoint = new Uri(http.BaseAddress!, "mcp"),
+                TransportMode = HttpTransportMode.StreamableHttp
+            },
+            http,
+            loggerFactory: null,
+            ownsHttpClient: true);
+
+        return await McpClient.CreateAsync(transport);
+    }
+
+    public void Dispose() {
+        try {
+            _factory.Dispose();
+
+            if (Directory.Exists(TempDir))
+                Directory.Delete(TempDir, true);
+        }
+        catch {
+            // Cleanup only; a leftover temp directory must never fail a test run.
+        }
+    }
+}

+ 57 - 0
Tests.Mcp/McpTestExtensions.cs

@@ -0,0 +1,57 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+
+namespace Tests.Mcp;
+
+internal static class McpTestExtensions {
+    /// <summary>Mirrors the server's tool serialization: web defaults plus enums as strings.</summary>
+    public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) {
+        Converters = { new JsonStringEnumConverter() }
+    };
+
+    /// <summary>Calls a tool that must succeed and deserializes its structured content.</summary>
+    public static async Task<T> CallOkAsync<T>(
+        this McpClient client,
+        string tool,
+        Dictionary<string, object?>? args = null) {
+        CallToolResult result = await client.CallToolAsync(tool, args);
+
+        AssertOk(tool, result);
+        Assert.NotNull(result.StructuredContent);
+
+        return result.StructuredContent.Value.Deserialize<T>(Json)
+               ?? throw new InvalidOperationException($"'{tool}' returned unusable structured content.");
+    }
+
+    /// <summary>Calls a tool that must succeed and returns its text content.</summary>
+    public static async Task<string> CallTextAsync(
+        this McpClient client,
+        string tool,
+        Dictionary<string, object?>? args = null) {
+        CallToolResult result = await client.CallToolAsync(tool, args);
+
+        AssertOk(tool, result);
+
+        return Text(result);
+    }
+
+    /// <summary>Calls a tool that must fail and returns the error text the agent would see.</summary>
+    public static async Task<string> CallErrorAsync(
+        this McpClient client,
+        string tool,
+        Dictionary<string, object?>? args = null) {
+        CallToolResult result = await client.CallToolAsync(tool, args);
+
+        Assert.True(result.IsError == true, $"Expected '{tool}' to fail, but it succeeded: {Text(result)}");
+
+        return Text(result);
+    }
+
+    public static string Text(CallToolResult result) =>
+        string.Join(Environment.NewLine, result.Content.OfType<TextContentBlock>().Select(t => t.Text));
+
+    private static void AssertOk(string tool, CallToolResult result) =>
+        Assert.False(result.IsError == true, $"'{tool}' failed: {Text(result)}");
+}

+ 370 - 0
Tests.Mcp/MutationToolTests.cs

@@ -0,0 +1,370 @@
+using ModelContextProtocol.Client;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Mcp.Tools;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     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.
+/// </summary>
+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<ImportYamlResponse>(
+            "upsert_resources", new Dictionary<string, object?> { ["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<ImportYamlResponse>(
+            "upsert_resources",
+            new Dictionary<string, object?> { ["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<ImportYamlResponse>(
+            "upsert_resources", new Dictionary<string, object?> {
+                ["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<ImportYamlResponse>(
+            "upsert_resources", new Dictionary<string, object?> {
+                ["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<string, object?> { ["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<ImportYamlResponse>(
+            "upsert_resources", new Dictionary<string, object?> { ["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<string, object?> { ["name"] = "rack-server" });
+
+        var stored = api.StoredYaml;
+        Assert.DoesNotContain("rack-server", stored);
+        SchemaAssert.ConformsToSchema(stored);
+
+        ConnectionList connections = await client.CallOkAsync<ConnectionList>("list_connections");
+        Assert.Equal(0, connections.Count);
+
+        ResourceDetail hostOs = await client.CallOkAsync<ResourceDetail>(
+            "get_resource", new Dictionary<string, object?> { ["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<string, object?> { ["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<string, object?> {
+            ["name"] = "rack-server",
+            ["newName"] = "compute-01"
+        });
+
+        var stored = api.StoredYaml;
+        Assert.DoesNotContain("rack-server", stored);
+        SchemaAssert.ConformsToSchema(stored);
+
+        ResourceDetail hostOs = await client.CallOkAsync<ResourceDetail>(
+            "get_resource", new Dictionary<string, object?> { ["name"] = "host-os" });
+        Assert.Contains("compute-01", hostOs.Yaml);
+
+        ConnectionList connections = await client.CallOkAsync<ConnectionList>("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<string, object?> {
+            ["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<string, object?> {
+            ["name"] = "rack-server",
+            ["cloneName"] = "compute-02"
+        });
+
+        ResourceDetail clone = await client.CallOkAsync<ResourceDetail>(
+            "get_resource", new Dictionary<string, object?> { ["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<string, object?> { ["name"] = "ghost", ["cloneName"] = "copy" }));
+
+        Assert.Contains("already exists", await client.CallErrorAsync("clone_resource",
+            new Dictionary<string, object?> { ["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<TagsResult>("edit_tags",
+            new Dictionary<string, object?> {
+                ["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<LabelsResult>("edit_labels",
+            new Dictionary<string, object?> {
+                ["name"] = "host-os",
+                ["set"] = new Dictionary<string, string> { ["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<string, object?> { ["name"] = "rack-server" }));
+
+        Assert.Contains("at least one", await client.CallErrorAsync("edit_labels",
+            new Dictionary<string, object?> { ["name"] = "host-os" }));
+    }
+
+    // -- add_connection / remove_connection ------------------------------------------------
+
+    private static Dictionary<string, object?> 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<ConnectionList>("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<ConnectionList>("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<string, object?> {
+            ["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<ConnectionList>("list_connections");
+        Assert.Equal(0, connections.Count);
+        Assert.DoesNotContain("portIndex", api.StoredYaml);
+    }
+}

+ 78 - 0
Tests.Mcp/ProtocolTests.cs

@@ -0,0 +1,78 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using RackPeek.Mcp.Tools;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     The protocol surface itself: the handshake identifies the server, every tool is
+///     advertised with enough description for an agent to use it unseen, and a bad tool
+///     name is an error rather than a hang or a crash.
+/// </summary>
+public class ProtocolTests {
+    /// <summary>Every tool the server ships. A rename here is a breaking change for clients.</summary>
+    public static readonly string[] ExpectedTools = [
+        "list_resources", "get_resource", "search_resources", "get_summary", "get_tree",
+        "list_connections", "get_subnets", "get_schema",
+        "upsert_resources", "delete_resource", "rename_resource", "clone_resource",
+        "edit_tags", "edit_labels", "add_connection", "remove_connection",
+        "export_ansible_inventory", "export_ssh_config", "export_hosts_file", "export_topology_mermaid",
+        "git_status", "git_commit",
+        "discover_docker", "discover_proxmox"
+    ];
+
+    [Fact]
+    public async Task The_handshake_identifies_the_server_by_name_and_version() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        Assert.Equal("rackpeek", client.ServerInfo.Name);
+        Assert.False(string.IsNullOrWhiteSpace(client.ServerInfo.Version));
+    }
+
+    [Fact]
+    public async Task Every_expected_tool_is_advertised_and_nothing_else() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        IList<McpClientTool> tools = await client.ListToolsAsync();
+
+        Assert.Equal(
+            ExpectedTools.OrderBy(t => t, StringComparer.Ordinal),
+            tools.Select(t => t.Name).OrderBy(t => t, StringComparer.Ordinal));
+    }
+
+    [Fact]
+    public async Task Every_tool_carries_a_description_an_agent_can_act_on() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        IList<McpClientTool> tools = await client.ListToolsAsync();
+
+        foreach (McpClientTool tool in tools)
+            Assert.False(
+                string.IsNullOrWhiteSpace(tool.Description),
+                $"Tool '{tool.Name}' has no description.");
+    }
+
+    [Fact]
+    public async Task Calling_a_tool_that_does_not_exist_is_a_clean_error() {
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        await Assert.ThrowsAnyAsync<McpException>(async () =>
+            await client.CallToolAsync("does_not_exist"));
+    }
+
+    [Fact]
+    public async Task Two_clients_can_talk_to_the_same_server_at_once() {
+        // Stateless streamable HTTP: no session to collide on.
+        using var api = new McpFixture();
+        await using McpClient first = await api.ConnectAsync();
+        await using McpClient second = await api.ConnectAsync();
+
+        await Task.WhenAll(
+            first.CallOkAsync<ResourceList>("list_resources"),
+            second.CallOkAsync<ResourceList>("list_resources"));
+    }
+}

+ 288 - 0
Tests.Mcp/QueryToolTests.cs

@@ -0,0 +1,288 @@
+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;
+
+/// <summary>
+///     The read half of the tool surface, over the seed inventory in
+///     <see cref="TestData.Seed" />. Assertions state exact expectations — the seed is
+///     small enough that anything looser would just be hiding a wrong answer.
+/// </summary>
+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<ResourceList>("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<ResourceList>(
+            "list_resources", new Dictionary<string, object?> { ["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<ResourceList>(
+            "list_resources", new Dictionary<string, object?> { ["tag"] = "prod" });
+        ResourceList byLabel = await client.CallOkAsync<ResourceList>(
+            "list_resources", new Dictionary<string, object?> { ["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<ResourceList>(
+            "list_resources", new Dictionary<string, object?> { ["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<ResourceDetail>(
+            "get_resource", new Dictionary<string, object?> { ["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<ResourceDetail>(
+            "get_resource", new Dictionary<string, object?> { ["name"] = "host-os" });
+
+        ImportYamlResponse response = await client.CallOkAsync<ImportYamlResponse>(
+            "upsert_resources", new Dictionary<string, object?> { ["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<string, object?> { ["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<SearchResults>(
+            "search_resources", new Dictionary<string, object?> { ["query"] = "grafana" });
+        SearchResults byIp = await client.CallOkAsync<SearchResults>(
+            "search_resources", new Dictionary<string, object?> { ["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<SearchResults>(
+            "search_resources", new Dictionary<string, object?> { ["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<InfrastructureSummary>("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<TreeResult>("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<TreeResult>(
+            "get_tree", new Dictionary<string, object?> { ["hardwareName"] = "rack-switch" });
+
+        Assert.Equal("rack-switch", Assert.Single(tree.Hardware).HardwareName);
+
+        var error = await client.CallErrorAsync(
+            "get_tree", new Dictionary<string, object?> { ["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<ConnectionList>("list_connections");
+        ConnectionList forSwitch = await client.CallOkAsync<ConnectionList>(
+            "list_connections", new Dictionary<string, object?> { ["resource"] = "rack-switch" });
+        ConnectionList forHost = await client.CallOkAsync<ConnectionList>(
+            "list_connections", new Dictionary<string, object?> { ["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<JsonElement>("get_subnets");
+        JsonElement filtered = await client.CallOkAsync<JsonElement>(
+            "get_subnets", new Dictionary<string, object?> { ["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<string, object?> { ["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<SchemaInfo>("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<ResourceList>("list_resources");
+        Assert.Equal(45, list.Count);
+
+        TreeResult tree = await client.CallOkAsync<TreeResult>("get_tree");
+        Assert.NotEmpty(tree.Hardware);
+
+        foreach (var name in new[] { "proxmox-node01", "pfsense-fw", "plex", "proxmox-cluster-node01" }) {
+            ResourceDetail detail = await client.CallOkAsync<ResourceDetail>(
+                "get_resource", new Dictionary<string, object?> { ["name"] = name });
+            SchemaAssert.ConformsToSchema(detail.Yaml);
+        }
+    }
+}

+ 88 - 0
Tests.Mcp/SchemaAssert.cs

@@ -0,0 +1,88 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Text.Json;
+using Json.Schema;
+using YamlDotNet.RepresentationModel;
+
+namespace Tests.Mcp;
+
+/// <summary>
+///     Asserts YAML the MCP tools hand out (or persist) satisfies the published
+///     RackPeek schema, so the tool surface cannot drift away from the contract the
+///     rest of the world imports. Same approach as Tests.Discovery.
+/// </summary>
+public static class SchemaAssert {
+    // JsonSchema.Net keeps a process-wide registry keyed on $id, so loading the same
+    // schema from two test classes at once races. Load each one exactly once.
+    private static readonly ConcurrentDictionary<int, Lazy<JsonSchema>> _schemas = new();
+
+    public static void ConformsToSchema(string yaml, int version = 4) {
+        JsonSchema schema = _schemas.GetOrAdd(version, v => new Lazy<JsonSchema>(() =>
+                JsonSchema.FromText(
+                    File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "schemas", $"schema.v{v}.json"))),
+            LazyThreadSafetyMode.ExecutionAndPublication)).Value;
+
+        EvaluationResults results = schema.Evaluate(
+            ToJson(yaml),
+            new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical });
+
+        if (results.IsValid)
+            return;
+
+        var errors = new List<string>();
+        Collect(results, errors);
+
+        Assert.Fail($"YAML does not match schema v{version}:{Environment.NewLine}"
+                    + string.Join(Environment.NewLine, errors.Distinct())
+                    + Environment.NewLine + Environment.NewLine + yaml);
+    }
+
+    private static void Collect(EvaluationResults node, List<string> errors) {
+        if (node.Errors != null)
+            foreach (KeyValuePair<string, string> error in node.Errors)
+                errors.Add($"{node.InstanceLocation}: {error.Value}");
+
+        if (node.Details != null)
+            foreach (EvaluationResults child in node.Details)
+                Collect(child, errors);
+    }
+
+    private static JsonElement ToJson(string yaml) {
+        var stream = new YamlStream();
+        stream.Load(new StringReader(yaml));
+
+        using var document = JsonDocument.Parse(Convert(stream.Documents[0].RootNode));
+
+        return document.RootElement.Clone();
+    }
+
+    private static string Convert(YamlNode node) {
+        switch (node) {
+            case YamlScalarNode scalar:
+                if (scalar.Style is YamlDotNet.Core.ScalarStyle.SingleQuoted
+                    or YamlDotNet.Core.ScalarStyle.DoubleQuoted)
+                    return JsonSerializer.Serialize(scalar.Value);
+
+                if (int.TryParse(scalar.Value, out var i))
+                    return i.ToString();
+
+                if (double.TryParse(scalar.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
+                    return d.ToString(CultureInfo.InvariantCulture);
+
+                if (bool.TryParse(scalar.Value, out var b))
+                    return b.ToString().ToLowerInvariant();
+
+                return JsonSerializer.Serialize(scalar.Value);
+
+            case YamlSequenceNode sequence:
+                return "[" + string.Join(",", sequence.Children.Select(Convert)) + "]";
+
+            case YamlMappingNode mapping:
+                return "{" + string.Join(",", mapping.Children.Select(kvp =>
+                    JsonSerializer.Serialize(((YamlScalarNode)kvp.Key).Value) + ":" + Convert(kvp.Value))) + "}";
+
+            default:
+                return "null";
+        }
+    }
+}

+ 76 - 0
Tests.Mcp/TestData.cs

@@ -0,0 +1,76 @@
+namespace Tests.Mcp;
+
+internal static class TestData {
+    /// <summary>
+    ///     A small, fully-understood inventory: two pieces of connected hardware, a
+    ///     system on the server, two services on the system, one cabled connection.
+    ///     Small enough that every test can state its expectations exactly.
+    /// </summary>
+    public const string Seed =
+        """
+        version: 4
+        resources:
+          - kind: Server
+            name: rack-server
+            discoveryId: rpk1:sys:aaaaaaaaaaaaaaaa
+            tags:
+              - prod
+            labels:
+              ansible_host: 10.0.0.2
+            ports:
+              - type: rj45
+                speed: 1
+                count: 4
+          - kind: Switch
+            name: rack-switch
+            ports:
+              - type: rj45
+                speed: 1
+                count: 8
+          - kind: System
+            name: host-os
+            type: baremetal
+            os: debian
+            cores: 8
+            ram: 32
+            ip: 10.0.0.5
+            runsOn:
+              - rack-server
+            labels:
+              env: prod
+          - kind: Service
+            name: grafana
+            runsOn:
+              - host-os
+            network:
+              ip: 10.0.0.5
+              port: 3000
+              protocol: TCP
+          - kind: Service
+            name: prometheus
+            runsOn:
+              - host-os
+            network:
+              ip: 10.0.1.9
+              port: 9090
+              protocol: TCP
+        connections:
+          - a:
+              resource: rack-server
+              portGroup: 0
+              portIndex: 0
+            b:
+              resource: rack-switch
+              portGroup: 0
+              portIndex: 0
+            label: uplink
+        """;
+
+    /// <summary>The 46-resource demo inventory shipped with the repo, for breadth tests.</summary>
+    public static string DemoConfig() =>
+        File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestConfigs", "demo-config.yaml"));
+
+    /// <summary>Captured API output shared with Tests.Discovery, for the discovery tools.</summary>
+    public static string Fixture(string name) =>
+        File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
+}

+ 46 - 0
Tests.Mcp/Tests.Mcp.csproj

@@ -0,0 +1,46 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net10.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+        <IsPackable>false</IsPackable>
+    </PropertyGroup>
+
+    <!-- Every test in this project is end-to-end: a real MCP client speaking
+         JSON-RPC over streamable HTTP to the real server, through the real
+         domain layer, down to real YAML on disk. Tool classes are never called
+         directly — if a behaviour cannot be observed through the protocol, a
+         client cannot observe it either. -->
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1"/>
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12"/>
+        <PackageReference Include="ModelContextProtocol" Version="2.2.0"/>
+        <PackageReference Include="JsonSchema.Net" Version="9.4.0"/>
+        <PackageReference Include="YamlDotNet" Version="18.1.0"/>
+        <PackageReference Include="xunit" Version="2.9.3"/>
+        <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0"/>
+        <PackageReference Include="coverlet.collector" Version="10.0.1">
+            <PrivateAssets>all</PrivateAssets>
+            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+        </PackageReference>
+    </ItemGroup>
+
+    <ItemGroup>
+        <Using Include="Xunit"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\RackPeek.Domain\RackPeek.Domain.csproj"/>
+        <ProjectReference Include="..\RackPeek.Mcp\RackPeek.Mcp.csproj"/>
+        <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <None Include="..\Tests\TestConfigs\v4\11-demo-config.yaml" Link="TestConfigs\demo-config.yaml" CopyToOutputDirectory="PreserveNewest"/>
+        <None Include="..\Tests.Discovery\Fixtures\*.json" Link="Fixtures\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
+        <None Include="..\schemas\**\*.json" Link="schemas\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
+    </ItemGroup>
+
+</Project>

+ 0 - 4
Tests/TestConfigs/v3/11-demo-config.yaml

@@ -121,10 +121,6 @@ resources:
         speed: 1
         count: 2
     name: access-switch
-  - kind: AccessPoint
-    model: UniFi-U6-Pro
-    speed: 2.5
-    name: lounge-ap
   - kind: Ups
     model: APC-SmartUPS-2200
     va: 2200

+ 0 - 4
Tests/TestConfigs/v4/11-demo-config.yaml

@@ -121,10 +121,6 @@ resources:
         speed: 1
         count: 2
     name: access-switch
-  - kind: AccessPoint
-    model: UniFi-U6-Pro
-    speed: 2.5
-    name: lounge-ap
   - kind: Ups
     model: APC-SmartUPS-2200
     va: 2200

+ 7 - 2
justfile

@@ -66,6 +66,11 @@ test-cli: _check-dotnet
 test-discovery: _check-dotnet
     {{ _dotnet }} test Tests.Discovery
 
+[doc("Run MCP tests (fast; no Docker required; matches the mcp-tests CI job)")]
+[group("test")]
+test-mcp: _check-dotnet
+    {{ _dotnet }} test Tests.Mcp
+
 [doc("Install Playwright + browsers for E2E (first-time only)")]
 [group("test")]
 e2e-setup: _check-dotnet
@@ -78,9 +83,9 @@ e2e-setup: _check-dotnet
 test-e2e: _check-dotnet build-web
     cd Tests.E2e && {{ _dotnet }} test
 
-[doc("Run CLI + discovery + E2E tests (rebuilds Web image)")]
+[doc("Run CLI + discovery + MCP + E2E tests (rebuilds Web image)")]
 [group("test")]
-test-all: _check-dotnet build-web e2e-setup test-cli test-discovery test-e2e
+test-all: _check-dotnet build-web e2e-setup test-cli test-discovery test-mcp test-e2e
 
 [doc("Run full test suite (alias for test-all; matches CI / pre-PR checklist)")]
 [group("test")]