| 1 | #nullable enable |
| 2 | |
| 3 | using System.Text.Json; |
| 4 | using CascadeIDE.Contracts.Experimental; |
| 5 | using CascadeIDE.Features.Forge.Infrastructure; |
| 6 | using CascadeIDE.Features.Forge.Lens; |
| 7 | using CascadeIDE.Features.IdeMcp.Execution; |
| 8 | using CascadeIDE.Services; |
| 9 | |
| 10 | namespace CascadeIDE.Features.Forge.Mcp; |
| 11 | |
| 12 | /// <summary>Forge MCP tool handlers (ADR 0161 — vertical ownership).</summary> |
| 13 | internal 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 | |