Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Text.Json;
4using CascadeIDE.Contracts.Experimental;
5using CascadeIDE.Features.Forge.Infrastructure;
6using CascadeIDE.Features.Forge.Lens;
7using CascadeIDE.Features.IdeMcp.Execution;
8using CascadeIDE.Services;
9
10namespace CascadeIDE.Features.Forge.Mcp;
11
12/// <summary>Forge MCP tool handlers (ADR 0161 — vertical ownership).</summary>
13internal static class ForgeMcpHandlers
14{
15 private static readonly ForgeLensDeviceConnectService ForgeLensConnect = new();
16
17 public static void Register(ForgeMcpHostContext host, IMcpHandlerRegistrar add)
18 {
19 add.Add(IdeCommands.ForgeLensConnect, async (args, ct) =>
20 {
21 var baseUrl = ResolveForgeBaseUrl(host, args);
22 if (string.IsNullOrWhiteSpace(baseUrl))
23 return "Error: укажи base_url или [workspace.forge] в .cascade/workspace.toml.";
24
25 var (ok, message) = await ForgeLensConnect.ConnectAsync(baseUrl, ct).ConfigureAwait(false);
26 return ok ? message : "Error: " + message;
27 });
28
29 add.Add(IdeCommands.ForgeLensDisconnect, async (args, _) =>
30 {
31 var baseUrl = ResolveForgeBaseUrl(host, args);
32 if (string.IsNullOrWhiteSpace(baseUrl))
33 return "Error: base_url required.";
34
35 ForgeSlashCatalogOverlay.Clear(baseUrl);
36 return ForgeLensSecretsStorage.RemoveHost(baseUrl)
37 ? $"Forge Lens: credentials removed for {baseUrl}."
38 : $"Forge Lens: no credentials for {baseUrl}.";
39 });
40
41 add.Add(IdeCommands.ForgeLensAuthStatus, async (args, _) =>
42 {
43 var baseUrl = ResolveForgeBaseUrl(host, args);
44 if (string.IsNullOrWhiteSpace(baseUrl))
45 return "Error: base_url required.";
46
47 var cide = ForgeLensSecretsStorage.TryGetToken(baseUrl);
48 if (!string.IsNullOrEmpty(cide))
49 return $"Forge Lens: logged in (CIDE secrets) → {baseUrl}";
50
51 var shared = ForgeSharedCredentialReader.TryGetToken(baseUrl);
52 if (!string.IsNullOrEmpty(shared))
53 return $"Forge Lens: logged in (~/.forge/credentials.json) → {baseUrl}";
54
55 var (_, repo) = ForgeLensWorkspaceConfig.TryResolve(host.TryGetWorkspaceRoot());
56 var envHint = repo is not null ? $" workspace repo={repo}" : "";
57 return await Task.FromResult(
58 $"Forge Lens: not logged in for {baseUrl}.{envHint} Run forge_lens.connect or forge auth login.");
59 });
60
61 add.Add(IdeCommands.ForgeLensCreateIssue, async (args, ct) =>
62 {
63 var title = McpCommandJsonArgs.String(args, "title")?.Trim() ?? "";
64 if (title.Length == 0)
65 return "Error: title required.";
66
67 var ctx = ResolveForgeWriteContext(host, args);
68 if (ctx is null)
69 return "Error: укажи base_url+repo или [workspace.forge] в .cascade/workspace.toml.";
70
71 var (ok, message) = await ForgeLensWriteClient.CreateIssueAsync(
72 ctx.BaseUrl,
73 ctx.Repo,
74 ctx.ApiToken,
75 title,
76 McpCommandJsonArgs.String(args, "body"),
77 BuildForgeAnchors(args),
78 ct).ConfigureAwait(false);
79 return ok ? message : "Error: " + message;
80 });
81
82 add.Add(IdeCommands.ForgeLensCreateMergeRequest, async (args, ct) =>
83 {
84 var title = McpCommandJsonArgs.String(args, "title")?.Trim() ?? "";
85 var sourceBranch = McpCommandJsonArgs.String(args, "source_branch")?.Trim() ?? "";
86 if (title.Length == 0)
87 return "Error: title required.";
88 if (sourceBranch.Length == 0)
89 return "Error: source_branch required.";
90
91 var ctx = ResolveForgeWriteContext(host, args);
92 if (ctx is null)
93 return "Error: укажи base_url+repo или [workspace.forge] в .cascade/workspace.toml.";
94
95 var (ok, message) = await ForgeLensWriteClient.CreateMergeRequestAsync(
96 ctx.BaseUrl,
97 ctx.Repo,
98 ctx.ApiToken,
99 title,
100 sourceBranch,
101 McpCommandJsonArgs.String(args, "target_branch"),
102 BuildForgeAnchors(args),
103 ct).ConfigureAwait(false);
104 return ok ? message : "Error: " + message;
105 });
106
107 add.Add(IdeCommands.ForgeArtifactGoto, async (args, _) =>
108 {
109 var bracket = McpCommandJsonArgs.String(args, "bracket")?.Trim() ?? "";
110 if (bracket.Length == 0)
111 return "Error: bracket required (e.g. [FRG:pilot/issues/1]).";
112
113 if (!BracketForgeReferenceParser.TryParse(bracket, out var artifact, out var parseError))
114 return "Error: " + parseError;
115
116 var baseUrl = ResolveForgeBaseUrl(host, args);
117 if (string.IsNullOrWhiteSpace(baseUrl))
118 {
119 var (cfgBase, _) = ForgeLensWorkspaceConfig.TryResolve(host.TryGetWorkspaceRoot());
120 baseUrl = cfgBase;
121 }
122
123 if (string.IsNullOrWhiteSpace(baseUrl))
124 return "Error: base_url or [workspace.forge] required.";
125
126 var viewUrl = ForgeLensOpenService.BuildViewUrl(baseUrl, artifact);
127 if (!ForgeLensOpenService.TryOpenExternal(viewUrl, out var openError))
128 return "Error: " + openError;
129
130 var select = McpCommandJsonArgs.Bool(args, "select_code", defaultValue: true);
131 if (!string.IsNullOrWhiteSpace(artifact.CodeBracket))
132 {
133 var ws = host.TryGetWorkspaceRoot();
134 var solutionPath = host.TryGetAttachSolutionPath();
135 var indexDir = CascadeIDE.Features.HybridIndex.Application.HybridIndexIndexDirectoryRelative.ResolveOrDefault(
136 host.Vm.GetCascadeSettingsForExecutor().HybridIndex.IndexDir);
137 if (!ForgeLensOpenService.TryNavigateCodeTail(
138 artifact,
139 ws,
140 activeFilePath: null,
141 solutionPath,
142 indexDir,
143 host.Actions,
144 host.Vm.GetCascadeSettingsForExecutor().Intercom,
145 select,
146 out var navError))
147 {
148 return $"Opened {viewUrl}. Code navigation failed: {navError}";
149 }
150 }
151
152 return await Task.FromResult(
153 $"Opened {viewUrl}" + (artifact.CodeBracket is not null ? " and navigated to code anchor." : "."));
154 });
155 }
156
157 private static ForgeLensWriteContext? ResolveForgeWriteContext(
158 ForgeMcpHostContext host,
159 IReadOnlyDictionary<string, JsonElement>? args) =>
160 ForgeLensWriteClient.TryResolveContext(
161 host.TryGetWorkspaceRoot(),
162 ResolveForgeBaseUrl(host, args),
163 McpCommandJsonArgs.String(args, "repo"));
164
165 private static IReadOnlyList<ForgeLensAnchorPayload>? BuildForgeAnchors(IReadOnlyDictionary<string, JsonElement>? args)
166 {
167 var file = McpCommandJsonArgs.String(args, "file_path")?.Trim().Replace('\\', '/');
168 if (string.IsNullOrWhiteSpace(file))
169 return null;
170
171 var lineStart = McpCommandJsonArgs.Int(args, "line_start", 0);
172 if (lineStart <= 0)
173 return null;
174
175 var lineEndRaw = McpCommandJsonArgs.Int(args, "line_end", 0);
176 int? lineEnd = lineEndRaw > 0 ? lineEndRaw : null;
177 var memberKey = McpCommandJsonArgs.String(args, "member_key");
178 return [new ForgeLensAnchorPayload(file, lineStart, lineEnd, memberKey)];
179 }
180
181 private static string? ResolveForgeBaseUrl(
182 ForgeMcpHostContext host,
183 IReadOnlyDictionary<string, JsonElement>? args)
184 {
185 var fromArgs = McpCommandJsonArgs.String(args, "base_url")?.Trim();
186 if (!string.IsNullOrWhiteSpace(fromArgs))
187 return fromArgs.TrimEnd('/');
188
189 return ForgeLensWorkspaceConfig.TryResolveBaseUrl(host.TryGetWorkspaceRoot());
190 }
191}
192
View only · write via MCP/CIDE