Forge
csharp8e44aa49
1using System.Collections.Frozen;
2using System.Diagnostics;
3using System.Text;
4using System.Text.Json;
5using GitMcp.Core;
6using ModelContextProtocol.Protocol;
7using ModelContextProtocol.Server;
8using 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
14static string GetRepoRoot(string repoRoot) => GitWorkTree.GetRepoRoot(repoRoot);
15
16static (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
51static 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
60var toolsList = ToolCatalog.Build();
61
62static string GetString(IReadOnlyDictionary<string, JsonElement> args, string key)
63 => args.TryGetValue(key, out var v) ? v.GetString() ?? "" : "";
64
65static bool GetBool(IReadOnlyDictionary<string, JsonElement> args, string key)
66 => args.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.True;
67
68static 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
80static 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
83static 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
97var 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
322var transport = new StdioServerTransport("GitMcp");
323await using var server = McpServer.Create(transport, options);
324await server.RunAsync();
325return 0;
326
View only · write via MCP/CIDE