| 1 | using System.Collections.Frozen; |
| 2 | using System.Diagnostics; |
| 3 | using System.Text; |
| 4 | using System.Text.Json; |
| 5 | using GitMcp.Core; |
| 6 | using ModelContextProtocol.Protocol; |
| 7 | using ModelContextProtocol.Server; |
| 8 | using Tool = ModelContextProtocol.Protocol.Tool; |
| 9 | |
| 10 | // MCP-сервер «Git»: status, diff, log, fetch, pull, branch, show, submodule, commit, push. |
| 11 | // Логика argv — GitMcp.Core (паритет с Cascade IDE, ADR 0019). |
| 12 | // Корень репо: GitWorkTree.GetRepoRoot (поддержка субмодуля: .git — файл). |
| 13 | |
| 14 | static string GetRepoRoot(string repoRoot) => GitWorkTree.GetRepoRoot(repoRoot); |
| 15 | |
| 16 | static (string output, int exitCode) RunGitRaw(string root, IReadOnlyList<string> args, Encoding? encoding = null) |
| 17 | { |
| 18 | encoding ??= Encoding.UTF8; |
| 19 | var psi = new ProcessStartInfo |
| 20 | { |
| 21 | FileName = "git", |
| 22 | WorkingDirectory = root, |
| 23 | RedirectStandardInput = true, |
| 24 | RedirectStandardOutput = true, |
| 25 | RedirectStandardError = true, |
| 26 | StandardOutputEncoding = encoding, |
| 27 | StandardErrorEncoding = encoding, |
| 28 | CreateNoWindow = true, |
| 29 | UseShellExecute = false |
| 30 | }; |
| 31 | foreach (var a in args) |
| 32 | psi.ArgumentList.Add(a); |
| 33 | |
| 34 | using var p = Process.Start(psi); |
| 35 | if (p == null) |
| 36 | throw new InvalidOperationException("Failed to start git."); |
| 37 | p.StandardInput.Close(); |
| 38 | var stdoutTask = p.StandardOutput.ReadToEndAsync(); |
| 39 | var stderrTask = p.StandardError.ReadToEndAsync(); |
| 40 | if (!p.WaitForExit(TimeSpan.FromSeconds(15))) |
| 41 | { |
| 42 | p.Kill(entireProcessTree: true); |
| 43 | throw new InvalidOperationException("git timed out after 15s."); |
| 44 | } |
| 45 | var stdout = stdoutTask.GetAwaiter().GetResult(); |
| 46 | var stderr = stderrTask.GetAwaiter().GetResult(); |
| 47 | var combined = (stdout.TrimEnd() + "\n" + stderr.TrimEnd()).Trim(); |
| 48 | return (combined, p.ExitCode); |
| 49 | } |
| 50 | |
| 51 | static string RunGit(string repoRoot, IReadOnlyList<string> args, Encoding? encoding = null) |
| 52 | { |
| 53 | var root = GetRepoRoot(repoRoot); |
| 54 | var (output, exitCode) = RunGitRaw(root, args, encoding); |
| 55 | if (exitCode != 0) |
| 56 | throw new InvalidOperationException($"git exit {exitCode}: {output}"); |
| 57 | return output; |
| 58 | } |
| 59 | |
| 60 | var toolsList = ToolCatalog.Build(); |
| 61 | |
| 62 | static string GetString(IReadOnlyDictionary<string, JsonElement> args, string key) |
| 63 | => args.TryGetValue(key, out var v) ? v.GetString() ?? "" : ""; |
| 64 | |
| 65 | static bool GetBool(IReadOnlyDictionary<string, JsonElement> args, string key) |
| 66 | => args.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.True; |
| 67 | |
| 68 | static bool GetBoolOrDefault(IReadOnlyDictionary<string, JsonElement> args, string key, bool defaultValue) |
| 69 | { |
| 70 | if (!args.TryGetValue(key, out var v)) |
| 71 | return defaultValue; |
| 72 | return v.ValueKind switch |
| 73 | { |
| 74 | JsonValueKind.True => true, |
| 75 | JsonValueKind.False => false, |
| 76 | _ => defaultValue |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | static int GetInt(IReadOnlyDictionary<string, JsonElement> args, string key, int defaultValue) |
| 81 | => args.TryGetValue(key, out var v) && v.TryGetInt32(out var n) ? n : defaultValue; |
| 82 | |
| 83 | static IReadOnlyList<string> GetStringArray(IReadOnlyDictionary<string, JsonElement> args, string key) |
| 84 | { |
| 85 | if (!args.TryGetValue(key, out var v) || v.ValueKind != JsonValueKind.Array) |
| 86 | return []; |
| 87 | var list = new List<string>(); |
| 88 | foreach (var e in v.EnumerateArray()) |
| 89 | { |
| 90 | var s = e.GetString(); |
| 91 | if (!string.IsNullOrWhiteSpace(s)) |
| 92 | list.Add(s.Trim()); |
| 93 | } |
| 94 | return list; |
| 95 | } |
| 96 | |
| 97 | var options = new McpServerOptions |
| 98 | { |
| 99 | ServerInfo = new Implementation { Name = "GitMcp", Version = "0.3.2" }, |
| 100 | ProtocolVersion = "2024-11-05", |
| 101 | Capabilities = new ServerCapabilities { Tools = new ToolsCapability { ListChanged = false } }, |
| 102 | Handlers = new McpServerHandlers |
| 103 | { |
| 104 | ListToolsHandler = (_, _) => ValueTask.FromResult(new ListToolsResult { Tools = toolsList }), |
| 105 | CallToolHandler = (request, cancellationToken) => |
| 106 | { |
| 107 | var name = request.Params?.Name ?? ""; |
| 108 | var args = request.Params?.Arguments is IReadOnlyDictionary<string, JsonElement> a ? a : FrozenDictionary<string, JsonElement>.Empty; |
| 109 | try |
| 110 | { |
| 111 | var workspacePath = GetString(args, "workspace_path"); |
| 112 | if (string.IsNullOrWhiteSpace(workspacePath)) |
| 113 | throw new ArgumentException("workspace_path is required."); |
| 114 | string text; |
| 115 | switch (name) |
| 116 | { |
| 117 | case "git_status": |
| 118 | { |
| 119 | var parts = new List<string>(); |
| 120 | foreach (var cmd in GitCommandBuilder.StatusMcpSequence()) |
| 121 | parts.Add(RunGit(workspacePath, cmd)); |
| 122 | text = string.Join("\n\n", parts); |
| 123 | break; |
| 124 | } |
| 125 | case "git_diff": |
| 126 | var path = GetString(args, "path"); |
| 127 | var staged = GetBool(args, "staged"); |
| 128 | text = RunGit(workspacePath, GitCommandBuilder.Diff(staged, path)); |
| 129 | break; |
| 130 | case "git_log": |
| 131 | var n = GetInt(args, "n", 20); |
| 132 | text = RunGit(workspacePath, GitCommandBuilder.Log(n)); |
| 133 | break; |
| 134 | case "git_commit": |
| 135 | var message = GetString(args, "message"); |
| 136 | if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("message is required for git_commit."); |
| 137 | var paths = GetStringArray(args, "paths"); |
| 138 | RunGit(workspacePath, GitCommandBuilder.Add(paths)); |
| 139 | text = RunGit(workspacePath, GitCommandBuilder.Commit(message)); |
| 140 | break; |
| 141 | case "git_push": |
| 142 | var remote = GetString(args, "remote"); |
| 143 | var branch = GetString(args, "branch"); |
| 144 | var pushDry = GetBool(args, "dry_run"); |
| 145 | text = RunGit(workspacePath, GitCommandBuilder.Push(remote, branch, defaultOriginWhenRemoteEmpty: true, pushDry)); |
| 146 | break; |
| 147 | case "git_fetch": |
| 148 | var fetchAll = GetBool(args, "all"); |
| 149 | var fetchPrune = GetBool(args, "prune"); |
| 150 | var fetchRemote = GetString(args, "remote"); |
| 151 | var fetchDry = GetBool(args, "dry_run"); |
| 152 | var fetchR = GitCommandBuilder.Fetch(fetchAll, fetchPrune, fetchRemote, fetchDry); |
| 153 | if (!fetchR.IsSuccess) |
| 154 | throw new ArgumentException(fetchR.Error); |
| 155 | text = RunGit(workspacePath, fetchR.Args!); |
| 156 | break; |
| 157 | case "git_pull": |
| 158 | var pullRem = GetString(args, "remote").Trim(); |
| 159 | var pullBr = GetString(args, "branch").Trim(); |
| 160 | var ffOnly = GetBoolOrDefault(args, "ff_only", true); |
| 161 | var pullDry = GetBool(args, "dry_run"); |
| 162 | var pullR = GitCommandBuilder.Pull(pullRem, pullBr, ffOnly, pullDry); |
| 163 | if (!pullR.IsSuccess) |
| 164 | throw new ArgumentException(pullR.Error); |
| 165 | text = RunGit(workspacePath, pullR.Args!); |
| 166 | break; |
| 167 | case "git_branch": |
| 168 | var bAction = GetString(args, "action").Trim(); |
| 169 | if (string.IsNullOrWhiteSpace(bAction)) bAction = "list"; |
| 170 | switch (bAction.ToLowerInvariant()) |
| 171 | { |
| 172 | case "list": |
| 173 | text = RunGit(workspacePath, GitCommandBuilder.BranchList().Args!); |
| 174 | break; |
| 175 | case "create": |
| 176 | var bn = GetString(args, "name").Trim(); |
| 177 | var sp = GetString(args, "start_point").Trim(); |
| 178 | var createR = GitCommandBuilder.BranchCreate(bn, string.IsNullOrWhiteSpace(sp) ? null : sp); |
| 179 | if (!createR.IsSuccess) |
| 180 | throw new ArgumentException(createR.Error); |
| 181 | text = RunGit(workspacePath, createR.Args!); |
| 182 | break; |
| 183 | case "delete": |
| 184 | var dn = GetString(args, "name").Trim(); |
| 185 | var force = GetBool(args, "force"); |
| 186 | var delR = GitCommandBuilder.BranchDelete(dn, force); |
| 187 | if (!delR.IsSuccess) |
| 188 | throw new ArgumentException(delR.Error); |
| 189 | text = RunGit(workspacePath, delR.Args!); |
| 190 | break; |
| 191 | default: |
| 192 | throw new ArgumentException("git_branch: action must be list, create, or delete."); |
| 193 | } |
| 194 | break; |
| 195 | case "git_show": |
| 196 | var rev = GetString(args, "rev").Trim(); |
| 197 | var showPath = GetString(args, "path"); |
| 198 | var statOnly = GetBool(args, "stat_only"); |
| 199 | var showR = GitCommandBuilder.Show(rev, showPath, statOnly); |
| 200 | if (!showR.IsSuccess) |
| 201 | throw new ArgumentException(showR.Error); |
| 202 | text = RunGit(workspacePath, showR.Args!); |
| 203 | break; |
| 204 | case "git_submodule": |
| 205 | var subAction = GetString(args, "action").Trim(); |
| 206 | if (string.IsNullOrWhiteSpace(subAction)) subAction = "status"; |
| 207 | switch (subAction.ToLowerInvariant()) |
| 208 | { |
| 209 | case "status": |
| 210 | text = RunGit(workspacePath, GitCommandBuilder.SubmoduleStatus().Args!); |
| 211 | break; |
| 212 | case "update": |
| 213 | var rec = GetBoolOrDefault(args, "recursive", true); |
| 214 | var subPath = GetString(args, "path").Trim(); |
| 215 | var subR = GitCommandBuilder.SubmoduleUpdate(rec, string.IsNullOrWhiteSpace(subPath) ? null : subPath); |
| 216 | if (!subR.IsSuccess) |
| 217 | throw new ArgumentException(subR.Error); |
| 218 | text = RunGit(workspacePath, subR.Args!); |
| 219 | break; |
| 220 | default: |
| 221 | throw new ArgumentException("git_submodule: action must be status or update."); |
| 222 | } |
| 223 | break; |
| 224 | case "git_preflight": |
| 225 | { |
| 226 | var stagedPreflight = GetBool(args, "staged"); |
| 227 | var includeUntracked = GetBoolOrDefault(args, "include_untracked", true); |
| 228 | var includePatches = GetBoolOrDefault(args, "include_patches", true); |
| 229 | |
| 230 | var changedOutput = RunGit(workspacePath, GitCommandBuilder.DiffNameOnly(stagedPreflight)); |
| 231 | var ignoreCrOutput = RunGit(workspacePath, GitCommandBuilder.DiffNameOnly(stagedPreflight, ignoreCrAtEol: true)); |
| 232 | var ignoreWsOutput = RunGit(workspacePath, GitCommandBuilder.DiffNameOnly(stagedPreflight, ignoreWhitespace: true, ignoreCrAtEol: true)); |
| 233 | |
| 234 | var changed = GitPreflight.ParseNameOnlyOutput(changedOutput); |
| 235 | var untracked = includeUntracked |
| 236 | ? GitPreflight.ParseNameOnlyOutput(RunGit(workspacePath, GitCommandBuilder.ListUntracked())) |
| 237 | : []; |
| 238 | var ignoreCr = GitPreflight.ParseNameOnlyOutput(ignoreCrOutput); |
| 239 | var ignoreWs = GitPreflight.ParseNameOnlyOutput(ignoreWsOutput); |
| 240 | |
| 241 | Dictionary<string, string>? patches = null; |
| 242 | if (includePatches && changed.Count > 0) |
| 243 | { |
| 244 | patches = new Dictionary<string, string>(StringComparer.Ordinal); |
| 245 | foreach (var file in changed) |
| 246 | { |
| 247 | var patchArgs = GitCommandBuilder.DiffPatchForPath(stagedPreflight, file); |
| 248 | if (!patchArgs.IsSuccess) |
| 249 | continue; |
| 250 | patches[file] = RunGit(workspacePath, patchArgs.Args!); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | var report = GitPreflight.BuildReport(changed, untracked, ignoreCr, ignoreWs, patches); |
| 255 | text = JsonSerializer.Serialize(new |
| 256 | { |
| 257 | success = true, |
| 258 | staged = stagedPreflight, |
| 259 | changed_files = report.ChangedFiles, |
| 260 | untracked_files = report.UntrackedFiles, |
| 261 | semantic_files = report.SemanticFiles, |
| 262 | whitespace_only_files = report.WhitespaceOnlyFiles, |
| 263 | eol_only_files = report.EolOnlyFiles, |
| 264 | bom_only_files = report.BomOnlyFiles, |
| 265 | suggested_safe_fix_commands = report.SuggestedSafeFixCommands |
| 266 | }); |
| 267 | break; |
| 268 | } |
| 269 | case "git_preflight_fix_safe": |
| 270 | { |
| 271 | var includePatches = GetBoolOrDefault(args, "include_patches", true); |
| 272 | RunGit(workspacePath, GitCommandBuilder.AddRenormalize()); |
| 273 | |
| 274 | var changedOutput = RunGit(workspacePath, GitCommandBuilder.DiffNameOnly(staged: false)); |
| 275 | var ignoreCrOutput = RunGit(workspacePath, GitCommandBuilder.DiffNameOnly(staged: false, ignoreCrAtEol: true)); |
| 276 | var ignoreWsOutput = RunGit(workspacePath, GitCommandBuilder.DiffNameOnly(staged: false, ignoreWhitespace: true, ignoreCrAtEol: true)); |
| 277 | var untrackedOutput = RunGit(workspacePath, GitCommandBuilder.ListUntracked()); |
| 278 | |
| 279 | var changed = GitPreflight.ParseNameOnlyOutput(changedOutput); |
| 280 | var untracked = GitPreflight.ParseNameOnlyOutput(untrackedOutput); |
| 281 | var ignoreCr = GitPreflight.ParseNameOnlyOutput(ignoreCrOutput); |
| 282 | var ignoreWs = GitPreflight.ParseNameOnlyOutput(ignoreWsOutput); |
| 283 | |
| 284 | Dictionary<string, string>? patches = null; |
| 285 | if (includePatches && changed.Count > 0) |
| 286 | { |
| 287 | patches = new Dictionary<string, string>(StringComparer.Ordinal); |
| 288 | foreach (var file in changed) |
| 289 | { |
| 290 | var patchArgs = GitCommandBuilder.DiffPatchForPath(staged: false, file); |
| 291 | if (!patchArgs.IsSuccess) |
| 292 | continue; |
| 293 | patches[file] = RunGit(workspacePath, patchArgs.Args!); |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | var report = GitPreflight.BuildReport(changed, untracked, ignoreCr, ignoreWs, patches); |
| 298 | text = JsonSerializer.Serialize(new |
| 299 | { |
| 300 | success = true, |
| 301 | applied = new[] { "git add --renormalize ." }, |
| 302 | changed_files = report.ChangedFiles, |
| 303 | untracked_files = report.UntrackedFiles, |
| 304 | semantic_files = report.SemanticFiles, |
| 305 | whitespace_only_files = report.WhitespaceOnlyFiles, |
| 306 | eol_only_files = report.EolOnlyFiles, |
| 307 | bom_only_files = report.BomOnlyFiles, |
| 308 | suggested_safe_fix_commands = report.SuggestedSafeFixCommands |
| 309 | }); |
| 310 | break; |
| 311 | } |
| 312 | default: throw new ArgumentException($"Unknown tool: {name}."); |
| 313 | } |
| 314 | return ValueTask.FromResult(new CallToolResult { Content = [new TextContentBlock { Text = text }], IsError = false }); |
| 315 | } |
| 316 | catch (ArgumentException ex) { return ValueTask.FromResult(new CallToolResult { Content = [new TextContentBlock { Text = $"Error: {ex.Message}" }], IsError = true }); } |
| 317 | catch (Exception ex) { return ValueTask.FromResult(new CallToolResult { Content = [new TextContentBlock { Text = "Error: " + ex.Message }], IsError = true }); } |
| 318 | } |
| 319 | } |
| 320 | }; |
| 321 | |
| 322 | var transport = new StdioServerTransport("GitMcp"); |
| 323 | await using var server = McpServer.Create(transport, options); |
| 324 | await server.RunAsync(); |
| 325 | return 0; |
| 326 | |