QueryTools.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. using System.ComponentModel;
  2. using Microsoft.Extensions.DependencyInjection;
  3. using ModelContextProtocol;
  4. using ModelContextProtocol.Server;
  5. using RackPeek.Domain.Helpers;
  6. using RackPeek.Domain.Persistence;
  7. using RackPeek.Domain.Persistence.Yaml;
  8. using RackPeek.Domain.Resources;
  9. using RackPeek.Domain.Resources.Connections;
  10. using RackPeek.Domain.Resources.Hardware;
  11. using RackPeek.Domain.Resources.Services.UseCases;
  12. using RackPeek.Domain.Resources.SystemResources.UseCases;
  13. using RackPeek.Domain.Search;
  14. namespace RackPeek.Mcp.Tools;
  15. public sealed record ResourceList(int Count, List<ResourceRow> Resources);
  16. public sealed record SearchResults(IReadOnlyList<SearchResult> Matches);
  17. public sealed record TreeResult(List<HardwareTree> Hardware);
  18. public sealed record ConnectionList(int Count, IReadOnlyList<Connection> Connections);
  19. public sealed record ResourceRow(
  20. string Name,
  21. string Kind,
  22. string? Ip,
  23. List<string> RunsOn,
  24. string[] Tags,
  25. Dictionary<string, string> Labels);
  26. public sealed record ResourceDetail(
  27. [property: Description("The resource and its connections as a RackPeek YAML document. " +
  28. "Edit it and pass it back to upsert_resources to change the resource.")]
  29. string Yaml,
  30. [property: Description("Names of resources that run on this one.")]
  31. List<string> Dependants);
  32. public sealed record InfrastructureSummary(
  33. HardwareSummary Hardware,
  34. SystemSummary Systems,
  35. AllServicesSummary Services,
  36. [property: Description("Every tag in use and how many resources carry it.")]
  37. Dictionary<string, int> Tags,
  38. [property: Description("Every label key in use and how many resources carry it.")]
  39. Dictionary<string, int> Labels);
  40. public sealed record SchemaInfo(
  41. [property: Description("The current config schema version.")]
  42. int Version,
  43. [property: Description("The JSON schema every inventory YAML document must conform to.")]
  44. string JsonSchema,
  45. string Guidance);
  46. [McpServerToolType]
  47. public sealed class QueryTools(IResourceCollection repo, IServiceProvider services) {
  48. internal const string UpsertGuidance =
  49. "A RackPeek document is YAML with 'version', 'resources' and optional 'connections'. " +
  50. "Every resource has 'kind' (Server, Switch, Firewall, Router, Accesspoint, Desktop, " +
  51. "Laptop, Ups, Other, System, Service), a unique 'name' (max 50 chars), and optional " +
  52. "'tags', 'labels', 'notes' and 'runsOn'. Containment rules: a Service runs on a " +
  53. "System; a System runs on hardware or on another System. Pass documents to " +
  54. "upsert_resources — merge mode only adds and updates, it never removes fields, " +
  55. "so use the dedicated tools (delete_resource, edit_tags, edit_labels, " +
  56. "remove_connection) to take things away.";
  57. [McpServerTool(Name = "list_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  58. [Description("Lists inventory resources with their key facts. All filters are optional and combine.")]
  59. public Task<ResourceList> ListResources(
  60. [Description("Only this kind: Server, Switch, Firewall, Router, Accesspoint, Desktop, Laptop, Ups, Other, System or Service.")]
  61. string? kind = null,
  62. [Description("Only resources carrying this tag.")]
  63. string? tag = null,
  64. [Description("Only resources carrying this label key.")]
  65. string? labelKey = null) {
  66. return ToolErrors.RunAsync(async () => {
  67. IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
  68. IReadOnlyList<(Resource, string)> ips = await repo.GetResourceIpsAsync();
  69. var ipByName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  70. foreach ((Resource resource, var ip) in ips) ipByName[resource.Name] = ip;
  71. var rows = all
  72. .Where(r => kind is null || r.Kind.Equals(kind.Trim(), StringComparison.OrdinalIgnoreCase))
  73. .Where(r => tag is null || r.Tags.Contains(tag.Trim(), StringComparer.OrdinalIgnoreCase))
  74. .Where(r => labelKey is null || r.Labels.Keys.Contains(labelKey.Trim(), StringComparer.OrdinalIgnoreCase))
  75. .OrderBy(r => r.Kind, StringComparer.OrdinalIgnoreCase)
  76. .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
  77. .Select(r => new ResourceRow(
  78. r.Name,
  79. r.Kind,
  80. ipByName.GetValueOrDefault(r.Name),
  81. r.RunsOn,
  82. r.Tags,
  83. r.Labels))
  84. .ToList();
  85. return new ResourceList(rows.Count, rows);
  86. });
  87. }
  88. [McpServerTool(Name = "get_resource", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  89. [Description("Gets one resource in full, as a YAML document that can be edited and passed back to upsert_resources.")]
  90. public Task<ResourceDetail> GetResource(
  91. [Description("The resource's name.")] string name) {
  92. return ToolErrors.RunAsync(async () => {
  93. Resource resource = await repo.GetByNameAsync(name)
  94. ?? throw new NotFoundException($"Resource '{name}' not found.");
  95. IReadOnlyList<Connection> connections = await repo.GetConnectionsForResourceAsync(resource.Name);
  96. IReadOnlyList<Resource> dependants = await repo.GetDependantsAsync(resource.Name);
  97. var yaml = YamlResourceCollection.SerializeRootAsync(new YamlRoot {
  98. Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
  99. Resources = [resource],
  100. Connections = connections.ToList()
  101. });
  102. return new ResourceDetail(yaml, dependants.Select(d => d.Name).ToList());
  103. });
  104. }
  105. [McpServerTool(Name = "search_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  106. [Description("Free-text search over resource names, IPs, tags and labels; returns the best matches first.")]
  107. public Task<SearchResults> SearchResources(
  108. [Description("The search text.")] string query,
  109. [Description("Maximum number of matches to return.")] int max = 8) {
  110. return ToolErrors.RunAsync(async () => {
  111. IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
  112. return new SearchResults(GlobalSearchService.Search(all, query, max));
  113. });
  114. }
  115. [McpServerTool(Name = "get_summary", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  116. [Description("Counts of everything in the inventory: hardware by kind, systems by type and OS, services, tags and labels.")]
  117. public Task<InfrastructureSummary> GetSummary() {
  118. return ToolErrors.RunAsync(async () => {
  119. HardwareSummary hardware = await services.GetRequiredService<GetHardwareUseCaseSummary>().ExecuteAsync();
  120. SystemSummary systems = await services.GetRequiredService<GetSystemSummaryUseCase>().ExecuteAsync();
  121. AllServicesSummary allServices = await services.GetRequiredService<GetServiceSummaryUseCase>().ExecuteAsync();
  122. Dictionary<string, int> tags = await repo.GetTagsAsync();
  123. Dictionary<string, int> labels = await repo.GetLabelsAsync();
  124. return new InfrastructureSummary(hardware, systems, allServices, tags, labels);
  125. });
  126. }
  127. [McpServerTool(Name = "get_tree", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  128. [Description("The containment forest: each hardware resource, the systems running on it, and the services running on those.")]
  129. public Task<TreeResult> GetTree(
  130. [Description("Only the tree under this hardware resource. Omit for the whole forest.")]
  131. string? hardwareName = null) {
  132. return ToolErrors.RunAsync(async () => {
  133. List<HardwareTree> forest = await services.GetRequiredService<IHardwareRepository>().GetTreeAsync();
  134. if (hardwareName is null) return new TreeResult(forest);
  135. var match = forest
  136. .Where(t => t.HardwareName.Equals(hardwareName.Trim(), StringComparison.OrdinalIgnoreCase))
  137. .ToList();
  138. if (match.Count == 0)
  139. throw new NotFoundException($"Hardware '{hardwareName}' not found.");
  140. return new TreeResult(match);
  141. });
  142. }
  143. [McpServerTool(Name = "list_connections", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  144. [Description("Physical port-to-port connections between hardware resources.")]
  145. public Task<ConnectionList> ListConnections(
  146. [Description("Only connections touching this resource. Omit for all connections.")]
  147. string? resource = null) {
  148. return ToolErrors.RunAsync(async () => {
  149. IReadOnlyList<Connection> connections = resource is null
  150. ? await repo.GetConnectionsAsync()
  151. : await repo.GetConnectionsForResourceAsync(resource);
  152. return new ConnectionList(connections.Count, connections);
  153. });
  154. }
  155. [McpServerTool(Name = "get_subnets", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  156. [Description("Groups service IPs into subnets, or lists the services inside one CIDR block.")]
  157. public Task<ServiceSubnetsResult> GetSubnets(
  158. [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.")]
  159. string? cidr = null,
  160. [Description("Prefix length used to group when no CIDR is given (default 24).")]
  161. int? prefix = null,
  162. CancellationToken cancellationToken = default) {
  163. return ToolErrors.RunAsync(async () => {
  164. ServiceSubnetsResult result = await services.GetRequiredService<ServiceSubnetsUseCase>()
  165. .ExecuteAsync(cidr, prefix, cancellationToken);
  166. if (result.IsInvalidCidr)
  167. throw new McpException($"'{result.InvalidCidrValue}' is not a valid CIDR block. Use e.g. 192.168.1.0/24.");
  168. return result;
  169. });
  170. }
  171. [McpServerTool(Name = "get_schema", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
  172. [Description("The JSON schema and authoring rules for RackPeek inventory YAML — read this before writing documents for upsert_resources.")]
  173. public Task<SchemaInfo> GetSchema() {
  174. return ToolErrors.RunAsync(() => {
  175. var version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
  176. var resourceName = $"schema.v{version}.json";
  177. using Stream? stream = typeof(QueryTools).Assembly.GetManifestResourceStream(resourceName);
  178. if (stream is null)
  179. throw new InvalidOperationException($"Embedded schema '{resourceName}' is missing from the build.");
  180. using var reader = new StreamReader(stream);
  181. var schema = reader.ReadToEnd();
  182. return Task.FromResult(new SchemaInfo(version, schema, UpsertGuidance));
  183. });
  184. }
  185. }