GitTools.cs 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. using System.ComponentModel;
  2. using Microsoft.Extensions.DependencyInjection;
  3. using ModelContextProtocol;
  4. using ModelContextProtocol.Server;
  5. using RackPeek.Domain.Git;
  6. using RackPeek.Domain.Git.UseCases;
  7. namespace RackPeek.Mcp.Tools;
  8. public sealed record GitStatusResult(
  9. [property: Description("False when the server has no GIT_TOKEN configured — every other field is then empty.")]
  10. bool Available,
  11. string? Message,
  12. string? Branch = null,
  13. [property: Description("Clean or Dirty.")] string? Status = null,
  14. string[]? ChangedFiles = null,
  15. bool HasRemote = false,
  16. [property: Description("Commits the remote is missing. Null when there is no remote.")]
  17. int? Ahead = null,
  18. [property: Description("Commits this repo is missing. Null when there is no remote.")]
  19. int? Behind = null,
  20. List<string>? RecentCommits = null);
  21. /// <summary>
  22. /// Version control over the config directory. Only active when the server is
  23. /// started with GIT_TOKEN — otherwise <see cref="NullGitRepository" /> is wired
  24. /// in and these tools explain how to enable it instead of failing obscurely.
  25. /// </summary>
  26. [McpServerToolType]
  27. public sealed class GitTools(IGitRepository repo, IServiceProvider services) {
  28. private const string _notConfigured =
  29. "Git integration is not configured. Start the server with GIT_TOKEN " +
  30. "(and optionally GIT_USERNAME) set to enable it.";
  31. [McpServerTool(Name = "git_status", UseStructuredContent = true, ReadOnly = true, OpenWorld = false)]
  32. [Description("The config repository's branch, dirty/clean state, changed files, remote sync state and recent commits.")]
  33. public Task<GitStatusResult> GitStatus() {
  34. return ToolErrors.RunAsync(() => {
  35. if (!repo.IsAvailable)
  36. return Task.FromResult(new GitStatusResult(false, _notConfigured));
  37. try {
  38. var hasRemote = repo.HasRemote();
  39. GitSyncStatus? sync = hasRemote ? repo.FetchAndGetSyncStatus() : null;
  40. GitLogEntry[] log;
  41. try {
  42. log = repo.GetLog(5);
  43. }
  44. catch {
  45. // An empty repository has no log yet; that is not an error.
  46. log = [];
  47. }
  48. return Task.FromResult(new GitStatusResult(
  49. true,
  50. sync?.Error,
  51. repo.GetCurrentBranch(),
  52. repo.GetStatus().ToString(),
  53. repo.GetChangedFiles(),
  54. hasRemote,
  55. sync?.Ahead,
  56. sync?.Behind,
  57. log.Select(e => $"{e.Hash} {e.Date} {e.Author}: {e.Message}").ToList()));
  58. }
  59. catch (Exception ex) when (ex is not McpException) {
  60. throw new McpException($"Git error: {ex.Message}");
  61. }
  62. });
  63. }
  64. [McpServerTool(Name = "git_commit", Idempotent = true, OpenWorld = false)]
  65. [Description("Stages everything in the config directory and commits it. A clean tree commits nothing and still succeeds.")]
  66. public Task<string> GitCommit(
  67. [Description("The commit message.")] string message,
  68. [Description("Also push to the configured remote.")] bool push = false) {
  69. return ToolErrors.RunAsync(async () => {
  70. if (!repo.IsAvailable)
  71. throw new McpException(_notConfigured);
  72. var error = await services.GetRequiredService<CommitAllUseCase>().ExecuteAsync(message);
  73. if (error != null)
  74. throw new McpException(error);
  75. if (!push)
  76. return "Committed.";
  77. var pushError = await services.GetRequiredService<PushUseCase>().ExecuteAsync();
  78. if (pushError != null)
  79. throw new McpException($"Committed, but the push failed: {pushError}");
  80. return "Committed and pushed.";
  81. });
  82. }
  83. }