MutationTools.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. using System.ComponentModel;
  2. using System.ComponentModel.DataAnnotations;
  3. using Microsoft.Extensions.DependencyInjection;
  4. using ModelContextProtocol;
  5. using ModelContextProtocol.Server;
  6. using RackPeek.Domain.Api;
  7. using RackPeek.Domain.Helpers;
  8. using RackPeek.Domain.Persistence;
  9. using RackPeek.Domain.Resources;
  10. using RackPeek.Domain.Resources.Connections;
  11. using RackPeek.Domain.UseCases;
  12. using RackPeek.Domain.UseCases.Labels;
  13. using RackPeek.Domain.UseCases.Tags;
  14. namespace RackPeek.Mcp.Tools;
  15. public sealed record TagsResult(string Name, string[] Tags);
  16. public sealed record LabelsResult(string Name, Dictionary<string, string> Labels);
  17. [McpServerToolType]
  18. public sealed class MutationTools(IResourceCollection repo, IServiceProvider services) {
  19. [McpServerTool(Name = "upsert_resources", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
  20. [Description("Creates or updates resources (and connections) from a RackPeek YAML document — the main " +
  21. "way to build and edit the inventory. Call get_schema first to learn the format, and " +
  22. "preview with dryRun before writing. Merge mode only adds and updates; it never removes.")]
  23. public Task<ImportYamlResponse> UpsertResources(
  24. [Description("A RackPeek YAML document: 'version', 'resources' and optional 'connections'.")]
  25. string yaml,
  26. [Description("Merge folds each incoming resource into the stored one, field by field. " +
  27. "Replace swaps each incoming resource in wholesale (other resources are untouched).")]
  28. MergeMode mode = MergeMode.Merge,
  29. [Description("When true, nothing is written — the response shows what would change.")]
  30. bool dryRun = false) =>
  31. RunUpsertAsync(services, new ImportYamlRequest { Yaml = yaml, Mode = mode, DryRun = dryRun });
  32. [McpServerTool(Name = "delete_resource", Destructive = true, Idempotent = true, OpenWorld = false)]
  33. [Description("Deletes a resource, detaches everything that ran on it, and removes its connections.")]
  34. public Task<string> DeleteResource(
  35. [Description("The resource's name.")] string name) {
  36. return ToolErrors.RunAsync(async () => {
  37. await services.GetRequiredService<IDeleteResourceUseCase<Resource>>().ExecuteAsync(name);
  38. return $"Deleted '{name}'.";
  39. });
  40. }
  41. [McpServerTool(Name = "rename_resource", Idempotent = true, OpenWorld = false)]
  42. [Description("Renames a resource and rewrites every runsOn reference and connection endpoint that pointed at it.")]
  43. public Task<string> RenameResource(
  44. [Description("The resource's current name.")] string name,
  45. [Description("The new name.")] string newName) {
  46. return ToolErrors.RunAsync(async () => {
  47. await services.GetRequiredService<IRenameResourceUseCase<Resource>>().ExecuteAsync(name, newName);
  48. return $"Renamed '{name}' to '{newName}'.";
  49. });
  50. }
  51. [McpServerTool(Name = "clone_resource", OpenWorld = false)]
  52. [Description("Copies a resource under a new name. The copy has no discovery id — it describes new gear, not the original machine.")]
  53. public Task<string> CloneResource(
  54. [Description("The resource to copy.")] string name,
  55. [Description("The copy's name.")] string cloneName) {
  56. return ToolErrors.RunAsync(async () => {
  57. await ResourceKindDispatch.CloneAsync(services, repo, name, cloneName);
  58. return $"Cloned '{name}' to '{cloneName}'.";
  59. });
  60. }
  61. [McpServerTool(Name = "edit_tags", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
  62. [Description("Adds and/or removes tags on a resource and returns the tags it ends up with.")]
  63. public Task<TagsResult> EditTags(
  64. [Description("The resource's name.")] string name,
  65. [Description("Tags to add.")] string[]? add = null,
  66. [Description("Tags to remove.")] string[]? remove = null) {
  67. return ToolErrors.RunAsync(async () => {
  68. if (add is not { Length: > 0 } && remove is not { Length: > 0 })
  69. throw new ValidationException("Pass at least one tag to add or remove.");
  70. foreach (var tag in add ?? [])
  71. await services.GetRequiredService<IAddTagUseCase<Resource>>().ExecuteAsync(name, tag);
  72. foreach (var tag in remove ?? [])
  73. await services.GetRequiredService<IRemoveTagUseCase<Resource>>().ExecuteAsync(name, tag);
  74. Resource resource = await repo.GetByNameAsync(name)
  75. ?? throw new NotFoundException($"Resource '{name}' not found.");
  76. return new TagsResult(resource.Name, resource.Tags);
  77. });
  78. }
  79. [McpServerTool(Name = "edit_labels", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
  80. [Description("Sets and/or removes key-value labels on a resource and returns the labels it ends up with.")]
  81. public Task<LabelsResult> EditLabels(
  82. [Description("The resource's name.")] string name,
  83. [Description("Labels to set — an existing key is overwritten.")]
  84. Dictionary<string, string>? set = null,
  85. [Description("Label keys to remove.")] string[]? remove = null) {
  86. return ToolErrors.RunAsync(async () => {
  87. if (set is not { Count: > 0 } && remove is not { Length: > 0 })
  88. throw new ValidationException("Pass at least one label to set or remove.");
  89. foreach ((var key, var value) in set ?? new Dictionary<string, string>())
  90. await services.GetRequiredService<IAddLabelUseCase<Resource>>().ExecuteAsync(name, key, value);
  91. foreach (var key in remove ?? [])
  92. await services.GetRequiredService<IRemoveLabelUseCase<Resource>>().ExecuteAsync(name, key);
  93. Resource resource = await repo.GetByNameAsync(name)
  94. ?? throw new NotFoundException($"Resource '{name}' not found.");
  95. return new LabelsResult(resource.Name, resource.Labels);
  96. });
  97. }
  98. [McpServerTool(Name = "add_connection", Idempotent = true, OpenWorld = false)]
  99. [Description("Connects a port on one hardware resource to a port on another. A port holds one connection — " +
  100. "connecting an occupied port replaces what was plugged into it. Port groups and indexes are " +
  101. "zero-based against the resource's 'ports' list (a group entry with count 4 has indexes 0-3).")]
  102. public Task<string> AddConnection(
  103. [Description("First endpoint's resource name.")] string resourceA,
  104. [Description("First endpoint's port group index.")] int portGroupA,
  105. [Description("First endpoint's port index within the group.")] int portIndexA,
  106. [Description("Second endpoint's resource name.")] string resourceB,
  107. [Description("Second endpoint's port group index.")] int portGroupB,
  108. [Description("Second endpoint's port index within the group.")] int portIndexB,
  109. [Description("Optional label, e.g. 'uplink'.")] string? label = null,
  110. [Description("Optional notes.")] string? notes = null) {
  111. return ToolErrors.RunAsync(async () => {
  112. var a = new PortReference { Resource = resourceA, PortGroup = portGroupA, PortIndex = portIndexA };
  113. var b = new PortReference { Resource = resourceB, PortGroup = portGroupB, PortIndex = portIndexB };
  114. await services.GetRequiredService<IAddConnectionUseCase>().ExecuteAsync(a, b, label, notes);
  115. return $"Connected {ConnectionMerger.Describe(new Connection { A = a, B = b, Label = label })}.";
  116. });
  117. }
  118. [McpServerTool(Name = "remove_connection", Destructive = true, Idempotent = true, OpenWorld = false)]
  119. [Description("Removes whatever connection is plugged into the given port.")]
  120. public Task<string> RemoveConnection(
  121. [Description("The port's resource name.")] string resource,
  122. [Description("The port's group index.")] int portGroup,
  123. [Description("The port's index within the group.")] int portIndex) {
  124. return ToolErrors.RunAsync(async () => {
  125. var port = new PortReference { Resource = resource, PortGroup = portGroup, PortIndex = portIndex };
  126. await services.GetRequiredService<IRemoveConnectionUseCase>().ExecuteAsync(port);
  127. return $"Removed any connection on {resource} port {portGroup}/{portIndex}.";
  128. });
  129. }
  130. /// <summary>
  131. /// Upserts get their own error mapping: the use case reports YAML/JSON problems
  132. /// through several exception types, and — matching the REST endpoint's contract —
  133. /// the message is always safe and useful to show the caller.
  134. /// </summary>
  135. internal static async Task<ImportYamlResponse> RunUpsertAsync(
  136. IServiceProvider services,
  137. ImportYamlRequest request) {
  138. try {
  139. return await services.GetRequiredService<UpsertInventoryUseCase>().ExecuteAsync(request);
  140. }
  141. catch (ValidationException ex) {
  142. throw new McpException($"Invalid input: {ex.Message}");
  143. }
  144. catch (Exception ex) when (ex is not McpException) {
  145. throw new McpException($"Import failed: {ex.Message}");
  146. }
  147. }
  148. }