DiscoveryTools.cs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. using System.ComponentModel;
  2. using Microsoft.Extensions.Configuration;
  3. using Microsoft.Extensions.DependencyInjection;
  4. using ModelContextProtocol;
  5. using ModelContextProtocol.Server;
  6. using RackPeek.Domain.Api;
  7. using RackPeek.Domain.Discovery;
  8. using RackPeek.Domain.Persistence;
  9. using RackPeek.Domain.Resources;
  10. using RackPeek.Domain.Resources.Services;
  11. using RackPeek.Domain.Resources.SystemResources;
  12. namespace RackPeek.Mcp.Tools;
  13. public sealed record DiscoveryResult(
  14. [property: Description("What was found, as a RackPeek YAML document ready for upsert_resources.")]
  15. string Yaml,
  16. int ResourceCount,
  17. [property: Description("Containers that were skipped because they publish no port reachable from outside the host.")]
  18. int Skipped,
  19. [property: Description("The merge outcome when apply was true; null when only previewing.")]
  20. ImportYamlResponse? Applied);
  21. /// <summary>
  22. /// Discovery run from the server against reachable infrastructure. Credentials
  23. /// are read from the server's own configuration, never from tool parameters, so
  24. /// secrets stay out of the conversation. `rpk discover system` has no tool here
  25. /// on purpose: it probes the machine it runs on, which for the server is its own
  26. /// container — run the CLI on the machine being inventoried instead.
  27. /// </summary>
  28. [McpServerToolType]
  29. public sealed class DiscoveryTools(IServiceProvider services) {
  30. [McpServerTool(Name = "discover_docker", UseStructuredContent = true, OpenWorld = true)]
  31. [Description("Reads a Docker (or Podman) engine and maps each container with a published port to a Service. " +
  32. "Preview first (apply=false), then apply to merge into the inventory — merging never removes anything.")]
  33. public Task<DiscoveryResult> DiscoverDocker(
  34. [Description("Docker endpoint, e.g. tcp://host:2375 or unix:///var/run/docker.sock. Defaults to DOCKER_HOST, then the local socket.")]
  35. string? dockerHost = null,
  36. [Description("Name of the machine the containers run on. Defaults to the engine's hostname.")]
  37. string? hostName = null,
  38. [Description("Merge the result into the inventory instead of only returning it.")]
  39. bool apply = false,
  40. CancellationToken cancellationToken = default) {
  41. return ToolErrors.RunAsync(async () => {
  42. SystemFacts host = await ReadHostAsync(cancellationToken);
  43. DockerApiClient client;
  44. try {
  45. client = new DockerApiClient(dockerHost);
  46. }
  47. catch (UriFormatException ex) {
  48. throw new McpException($"'{dockerHost}' is not a usable Docker endpoint. {ex.Message}");
  49. }
  50. using DockerApiClient _ = client;
  51. IReadOnlyList<DockerContainer> containers;
  52. try {
  53. containers = await client.ListContainersAsync(cancellationToken);
  54. }
  55. catch (Exception ex) when (
  56. ex is HttpRequestException or IOException or TimeoutException
  57. || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
  58. throw new McpException($"Could not reach Docker at {client.Endpoint}. {ex.Message}");
  59. }
  60. // Mirrors `rpk discover docker`: over TCP the engine describes itself (its
  61. // daemon id seeds identity, its hostname is what runsOn points at); locally
  62. // the host probe does, and the host System rides along for id-based merging.
  63. DockerEngineInfo? engine = client.IsLocal ? null : await client.GetInfoAsync(cancellationToken);
  64. SystemResource hostResource = SystemResourceMapper.ToResource(host, hostName);
  65. var effectiveHost = client.IsLocal
  66. ? hostResource.Name
  67. : hostName ?? engine?.Hostname ?? hostResource.Name;
  68. var seed = client.IsLocal
  69. ? host.MachineId ?? host.Hostname
  70. : engine?.Id ?? client.Endpoint;
  71. var serviceIp = client.IsLocal
  72. ? host.Ip
  73. : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip;
  74. List<Service> found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp);
  75. List<Resource> resources = client.IsLocal && found.Count > 0
  76. ? [hostResource, .. found]
  77. : [.. found];
  78. return await EmitAsync(resources, containers.Count - found.Count, apply);
  79. });
  80. }
  81. [McpServerTool(Name = "discover_proxmox", UseStructuredContent = true, OpenWorld = true)]
  82. [Description("Reads a Proxmox VE estate: each node becomes a Server plus a hypervisor System, each VM/LXC a System " +
  83. "running on it, already wired together. Credentials come from the server's RPK_PVE_TOKEN_ID / " +
  84. "RPK_PVE_TOKEN_SECRET configuration. Preview first (apply=false), then apply to merge.")]
  85. public Task<DiscoveryResult> DiscoverProxmox(
  86. [Description("Proxmox host, e.g. https://pve.lan:8006. A bare host name gets https and :8006.")]
  87. string host,
  88. [Description("Accept a self-signed certificate, which Proxmox ships with by default.")]
  89. bool insecure = false,
  90. [Description("Merge the result into the inventory instead of only returning it.")]
  91. bool apply = false,
  92. CancellationToken cancellationToken = default) {
  93. return ToolErrors.RunAsync(async () => {
  94. IConfiguration config = services.GetRequiredService<IConfiguration>();
  95. var tokenId = config[ProxmoxApiClient.TokenIdEnvironmentVariable];
  96. var tokenSecret = config[ProxmoxApiClient.TokenSecretEnvironmentVariable];
  97. if (string.IsNullOrWhiteSpace(tokenId) || string.IsNullOrWhiteSpace(tokenSecret))
  98. throw new McpException(
  99. "Proxmox credentials are not configured on the server. Start it with " +
  100. $"{ProxmoxApiClient.TokenIdEnvironmentVariable} and {ProxmoxApiClient.TokenSecretEnvironmentVariable} set.");
  101. ProxmoxApiClient client;
  102. try {
  103. client = new ProxmoxApiClient(host, tokenId, tokenSecret, insecure);
  104. }
  105. catch (UriFormatException ex) {
  106. throw new McpException($"'{host}' is not a usable host. {ex.Message}");
  107. }
  108. List<Resource> resources;
  109. try {
  110. resources = await ReadProxmoxAsync(client, cancellationToken);
  111. }
  112. catch (HttpRequestException ex) {
  113. var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException
  114. ? " Proxmox uses a self-signed certificate by default — try insecure=true."
  115. : string.Empty;
  116. throw new McpException($"Could not read {client.Endpoint}. {ex.Message}{hint}");
  117. }
  118. catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) {
  119. // HttpClient reports its timeout as a cancellation.
  120. throw new McpException($"{client.Endpoint} did not answer within the timeout.");
  121. }
  122. finally {
  123. client.Dispose();
  124. }
  125. return await EmitAsync(resources, 0, apply);
  126. });
  127. }
  128. private async Task<SystemFacts> ReadHostAsync(CancellationToken cancellationToken) {
  129. // An unsupported platform is not fatal for docker discovery — the containers can
  130. // still be read; only the host's own facts fall back to basics.
  131. return await SystemProbes.TryReadHostAsync(services.GetServices<ISystemProbe>(), cancellationToken)
  132. ?? SystemFactsParser.Parse(new RawSystemSnapshot {
  133. Hostname = Environment.MachineName,
  134. Cores = Environment.ProcessorCount
  135. });
  136. }
  137. /// <summary>Same read orchestration as `rpk discover proxmox`.</summary>
  138. private static async Task<List<Resource>> ReadProxmoxAsync(
  139. IProxmoxClient client,
  140. CancellationToken cancellationToken) {
  141. var scope = await client.GetIdentityScopeAsync(cancellationToken);
  142. IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
  143. var nodes = new List<ProxmoxNode>();
  144. var guests = new List<ProxmoxGuest>();
  145. foreach (ProxmoxNode listedNode in listed) {
  146. ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
  147. nodes.Add(node);
  148. foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
  149. IReadOnlyList<ProxmoxGuest> listedGuests =
  150. await client.GetGuestsAsync(node.Name, endpoint, cancellationToken);
  151. ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
  152. client.GetGuestConfigAsync(node.Name, endpoint, g.VmId, cancellationToken)));
  153. for (var i = 0; i < listedGuests.Count; i++)
  154. guests.Add(listedGuests[i] with {
  155. Os = configs[i].Os,
  156. Ip = configs[i].Ip,
  157. Disks = configs[i].DiskBytes,
  158. PassthroughAddresses = configs[i].PassthroughAddresses
  159. });
  160. }
  161. }
  162. return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
  163. }
  164. private async Task<DiscoveryResult> EmitAsync(List<Resource> resources, int skipped, bool apply) {
  165. var yaml = DiscoveryDocument.ToYaml(resources);
  166. ImportYamlResponse? applied = null;
  167. if (apply && resources.Count > 0)
  168. applied = await MutationTools.RunUpsertAsync(services, new ImportYamlRequest {
  169. Yaml = yaml,
  170. // Discovery can add and update but must never remove what the user wrote.
  171. Mode = MergeMode.Merge
  172. });
  173. return new DiscoveryResult(yaml, resources.Count, skipped, applied);
  174. }
  175. }