| 1 | using System.Text.Json; |
| 2 | using CascadeIDE.Services; |
| 3 | using Xunit; |
| 4 | using TestContext = Xunit.TestContext; |
| 5 | |
| 6 | namespace CascadeIDE.Tests; |
| 7 | |
| 8 | public sealed class IdeMcpServerDispatchTests |
| 9 | { |
| 10 | [Fact] |
| 11 | public async Task ProxyTool_MapsIdePrefixToCommandId() |
| 12 | { |
| 13 | var fake = new FakeActions(); |
| 14 | var args = new Dictionary<string, JsonElement> |
| 15 | { |
| 16 | ["path"] = JsonSerializer.SerializeToElement(@"C:\tmp\a.txt") |
| 17 | }; |
| 18 | |
| 19 | var result = await CallToolByConventionAsync(fake, "ide_open_file", args); |
| 20 | |
| 21 | Assert.Equal("OK", result); |
| 22 | Assert.Equal(IdeCommands.OpenFile, fake.LastCommandId); |
| 23 | Assert.Same(args, fake.LastArgs); |
| 24 | } |
| 25 | |
| 26 | [Fact] |
| 27 | public async Task DispatcherTool_UsesCommandIdFromArguments() |
| 28 | { |
| 29 | var fake = new FakeActions(); |
| 30 | var args = new Dictionary<string, JsonElement> |
| 31 | { |
| 32 | ["command_id"] = JsonSerializer.SerializeToElement(IdeCommands.ToggleTerminal), |
| 33 | ["args"] = JsonSerializer.SerializeToElement(new { visible = true }) |
| 34 | }; |
| 35 | |
| 36 | var result = await CallToolByConventionAsync(fake, "ide_execute_command", args); |
| 37 | |
| 38 | Assert.Equal("OK", result); |
| 39 | Assert.Equal(IdeCommands.ToggleTerminal, fake.LastCommandId); |
| 40 | Assert.NotNull(fake.LastArgs); |
| 41 | Assert.True(fake.LastArgs!.TryGetValue("visible", out var visEl) && visEl.GetBoolean()); |
| 42 | Assert.False(fake.LastArgs.ContainsKey("args")); |
| 43 | } |
| 44 | |
| 45 | [Fact] |
| 46 | public async Task DispatcherTool_MergesNestedArgsObjectForExecuteCommand() |
| 47 | { |
| 48 | var fake = new FakeActions(); |
| 49 | var args = new Dictionary<string, JsonElement> |
| 50 | { |
| 51 | ["command_id"] = JsonSerializer.SerializeToElement(IdeCommands.DebugLaunch), |
| 52 | ["args"] = JsonSerializer.SerializeToElement(new |
| 53 | { |
| 54 | workspace_path = @"D:\proj", |
| 55 | target_path = @"C:\Program Files\dotnet\dotnet.exe" |
| 56 | }) |
| 57 | }; |
| 58 | |
| 59 | var result = await CallToolByConventionAsync(fake, "ide_execute_command", args); |
| 60 | |
| 61 | Assert.Equal("OK", result); |
| 62 | Assert.Equal(IdeCommands.DebugLaunch, fake.LastCommandId); |
| 63 | Assert.NotNull(fake.LastArgs); |
| 64 | Assert.Equal(@"D:\proj", fake.LastArgs!["workspace_path"].GetString()); |
| 65 | Assert.Equal(@"C:\Program Files\dotnet\dotnet.exe", fake.LastArgs["target_path"].GetString()); |
| 66 | } |
| 67 | |
| 68 | [Fact] |
| 69 | public async Task ProxyTool_DottedCommandId_MapsUnderscoreToolToCanonicalCommandId() |
| 70 | { |
| 71 | var fake = new FakeActions(); |
| 72 | var result = await CallToolByConventionAsync(fake, "ide_intercom_reveal_attachment", null); |
| 73 | |
| 74 | Assert.Equal("OK", result); |
| 75 | Assert.Equal(IdeCommands.IntercomRevealAttachment, fake.LastCommandId); |
| 76 | } |
| 77 | |
| 78 | [Fact] |
| 79 | public async Task CompatibilityOverride_IdeBuildMapsToBuildStructured() |
| 80 | { |
| 81 | var fake = new FakeActions(); |
| 82 | var args = new Dictionary<string, JsonElement>(); |
| 83 | |
| 84 | var result = await CallToolByConventionAsync(fake, "ide_build", args); |
| 85 | |
| 86 | Assert.Equal("OK", result); |
| 87 | Assert.Equal(IdeCommands.BuildStructured, fake.LastCommandId); |
| 88 | } |
| 89 | |
| 90 | private static async Task<string> CallToolByConventionAsync( |
| 91 | IIdeMcpActions actions, |
| 92 | string toolName, |
| 93 | IReadOnlyDictionary<string, JsonElement>? args) |
| 94 | { |
| 95 | var mi = typeof(IdeMcpServer).GetMethod( |
| 96 | "CallToolByConventionAsync", |
| 97 | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); |
| 98 | Assert.NotNull(mi); |
| 99 | |
| 100 | var task = (Task<string>)mi!.Invoke( |
| 101 | null, |
| 102 | new object?[] { actions, toolName, args, TestContext.Current.CancellationToken })!; |
| 103 | return await task.ConfigureAwait(false); |
| 104 | } |
| 105 | |
| 106 | private sealed class FakeActions : IIdeMcpActions |
| 107 | { |
| 108 | public string? LastCommandId { get; private set; } |
| 109 | public IReadOnlyDictionary<string, JsonElement>? LastArgs { get; private set; } |
| 110 | |
| 111 | public Task<string> ExecuteCommandAsync(string commandId, IReadOnlyDictionary<string, JsonElement>? args, CancellationToken cancellationToken = default) |
| 112 | { |
| 113 | LastCommandId = commandId; |
| 114 | LastArgs = args; |
| 115 | return Task.FromResult("OK"); |
| 116 | } |
| 117 | |
| 118 | public void OpenFile(string path) => throw new NotImplementedException(); |
| 119 | public void LoadSolution(string path) => throw new NotImplementedException(); |
| 120 | |
| 121 | public Task<string> LoadSolutionAndWaitAsync(string path, CancellationToken cancellationToken = default) => |
| 122 | throw new NotImplementedException(); |
| 123 | public void SelectInEditor(string? filePath, int startLine, int startColumn, int endLine, int endColumn) => throw new NotImplementedException(); |
| 124 | public Task<string> GetEditorStateAsync(int? maxPreviewChars = null) => throw new NotImplementedException(); |
| 125 | public Task<string> GetEditorContentRangeAsync(int startLine, int endLine) => throw new NotImplementedException(); |
| 126 | public Task<string> GetOpenDocumentTextAsync(string? filePath, int? maxChars) => throw new NotImplementedException(); |
| 127 | public Task<string> ApplyEditAsync(string filePath, int startLine, int startColumn, int endLine, int endColumn, string newText) => |
| 128 | Task.FromResult("OK"); |
| 129 | |
| 130 | public Task<string> ReadWorkspaceFileAsync(string filePath, int? offset, int? limit, int? maxChars) => |
| 131 | Task.FromResult("""{"text":""}"""); |
| 132 | |
| 133 | public Task<string> SaveDocumentAsync(string? filePath, string? content) => |
| 134 | Task.FromResult("""{"file_path":"x"}"""); |
| 135 | public void GoToPosition(string? filePath, int line, int column, int? endLine = null, int? endColumn = null) => throw new NotImplementedException(); |
| 136 | public void RevealEditorRange(string? filePath, int startLine, int endLine, int? durationMs) => throw new NotImplementedException(); |
| 137 | public string GetSolutionInfo() => throw new NotImplementedException(); |
| 138 | public Task<string> GetSolutionFilesAsync() => throw new NotImplementedException(); |
| 139 | public Task<string> SearchWorkspaceTextAsync(string pattern, string? subPath, bool fixedString, string? glob, int maxMatches, string? rgPath) => throw new NotImplementedException(); |
| 140 | public Task<string> SearchWebPublicQueryAsync(string query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 141 | public Task<string> FetchWebPublicUrlAsync(string url, int? maxChars, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 142 | public Task<string> GetCurrentFileDiagnosticsAsync() => throw new NotImplementedException(); |
| 143 | public Task<string> GetCodeNavigationContextAsync(string mode, string? filePath, int? line, int? column, int? maxRelated, int? maxNodes, int? maxEdges, string? preset, IReadOnlyList<string>? includeKinds, IReadOnlyList<string>? excludeKinds, string? level) => throw new NotImplementedException(); |
| 144 | public Task<string> BuildAsync() => throw new NotImplementedException(); |
| 145 | public Task<string> BuildStructuredAsync() => throw new NotImplementedException(); |
| 146 | public Task<string> RunTestsAsync() => throw new NotImplementedException(); |
| 147 | public Task<string> RunAffectedTestsAsync(IReadOnlyList<string>? changedPaths = null) => throw new NotImplementedException(); |
| 148 | public Task<string> RunCodeCleanupAsync(string? includePath = null) => throw new NotImplementedException(); |
| 149 | public Task<string> GetCodeMetricsAsync(string? scope = null, string? path = null) => throw new NotImplementedException(); |
| 150 | public Task<string> GetIdeStateAsync() => throw new NotImplementedException(); |
| 151 | public Task<string> GetCockpitSurfaceAsync() => throw new NotImplementedException(); |
| 152 | public Task<string> GetUiModesDiagnosticsAsync() => throw new NotImplementedException(); |
| 153 | public Task<string> GitStatusAsync() => throw new NotImplementedException(); |
| 154 | public Task<string> GitDiffAsync(string? path = null, bool staged = false) => throw new NotImplementedException(); |
| 155 | public Task<string> GitCommitAsync(string message, IReadOnlyList<string>? paths = null) => throw new NotImplementedException(); |
| 156 | public Task<string> GitPushAsync(string? remote = null, string? branch = null, bool dryRun = false) => throw new NotImplementedException(); |
| 157 | public Task<string> GitLogAsync(int n = 20) => throw new NotImplementedException(); |
| 158 | public Task<string> GitFetchAsync(string? remote = null, bool all = false, bool prune = false, bool dryRun = false) => throw new NotImplementedException(); |
| 159 | public Task<string> GitPullAsync(string? remote = null, string? branch = null, bool ffOnly = true, bool dryRun = false) => throw new NotImplementedException(); |
| 160 | public Task<string> GitBranchAsync(string? action = null, string? name = null, string? startPoint = null, bool force = false) => throw new NotImplementedException(); |
| 161 | public Task<string> GitShowAsync(string rev, string? path = null, bool statOnly = false) => throw new NotImplementedException(); |
| 162 | public Task<string> GitSubmoduleAsync(string? action = null, string? path = null, bool recursive = true) => throw new NotImplementedException(); |
| 163 | public Task<string> GitPreflightAsync(bool staged = false, bool includeUntracked = true, bool includePatches = true) => throw new NotImplementedException(); |
| 164 | public Task<string> GitPreflightFixSafeAsync(bool includePatches = true) => throw new NotImplementedException(); |
| 165 | public string GetBuildOutput() => throw new NotImplementedException(); |
| 166 | public void SetBreakpoint(string filePath, int line, string? condition = null) => throw new NotImplementedException(); |
| 167 | public void RemoveBreakpoint(string filePath, int line) => throw new NotImplementedException(); |
| 168 | public void ShowPreview(string title, string content) => throw new NotImplementedException(); |
| 169 | public void ShowEditorPreview() => throw new NotImplementedException(); |
| 170 | public Task<string> RequestConfirmationAsync(string message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 171 | public void FocusEditor() => throw new NotImplementedException(); |
| 172 | public string GetUiTheme() => throw new NotImplementedException(); |
| 173 | public Task<string> SetUiThemeAsync(string themeJson) => throw new NotImplementedException(); |
| 174 | public Task<string> GetUiLayoutAsync() => throw new NotImplementedException(); |
| 175 | public Task<string> GetColorsUnderCursorAsync() => throw new NotImplementedException(); |
| 176 | public Task<string> GetControlAppearanceAsync(string? name) => throw new NotImplementedException(); |
| 177 | public Task<string> SetControlLayoutAsync(string controlName, string layoutJson) => throw new NotImplementedException(); |
| 178 | public Task<string> AddControlAsync(string parentName, string controlType, string? content, string? name) => throw new NotImplementedException(); |
| 179 | public Task<string> SetControlTextAsync(string controlName, string text) => throw new NotImplementedException(); |
| 180 | public Task<string> ClickControlAsync(string? controlName) => throw new NotImplementedException(); |
| 181 | public Task<string> SendKeysAsync(string? controlName, string keys) => throw new NotImplementedException(); |
| 182 | public Task<string> SetFocusAsync(string? controlName) => throw new NotImplementedException(); |
| 183 | public Task<string> HighlightControlAsync(string? controlName) => throw new NotImplementedException(); |
| 184 | public Task<string> SetPanelSizeAsync(string panel, double? width, double? height) => throw new NotImplementedException(); |
| 185 | public string GetSupportedEditorLanguages() => throw new NotImplementedException(); |
| 186 | public Task<string> GetDebugSnapshotAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 187 | public Task<string> WriteAgentNotesAsync(string content, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 188 | public Task<string> ReadAgentNotesAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 189 | public Task<string> AppendAgentNotesAsync(string content, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 190 | public Task<string> ListAgentNotesRevisionsAsync(int? limit = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 191 | public Task<string> RollbackAgentNotesAsync(string? revisionFile = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 192 | public Task<string> ReadHotContextAsync(string? activeScope = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 193 | public Task<string> RouteContextAsync(string query, string? activeScope = null, int? maxSections = null, int? maxChars = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 194 | public Task<string> MemoryHealthAsync(string? activeScope = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 195 | public Task<string> CompactHotContextAsync(bool apply = false, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 196 | public Task<string> ExtractFromArchiveAsync(string query, string? revisionFile = null, int? headLimit = null, int? contextLines = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 197 | public Task<string> UpsertAgentNotesSectionAsync(string sectionId, string content, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 198 | public Task<string> SearchAgentNotesAsync(string query, int? headLimit = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 199 | public Task<string> ReadKnowledgeFileAsync(string filePath, int? offset = null, int? limit = null, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 200 | public Task<string> ListKnowledgeFilesAsync(string? subdir = null, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 201 | public Task<string> WriteKnowledgeFileAsync(string filePath, string content, string? knowledgePath = null, bool saveRevision = true, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 202 | public Task<string> AppendKnowledgeFileAsync(string filePath, string content, string? knowledgePath = null, bool saveRevision = true, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 203 | public Task<string> UpsertKnowledgeSectionAsync(string filePath, string sectionId, string content, string? knowledgePath = null, bool saveRevision = true, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 204 | public Task<string> DeleteKnowledgeFileAsync(string filePath, string? knowledgePath = null, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 205 | public Task<string> DeleteKnowledgeSectionAsync(string filePath, string sectionId, string? knowledgePath = null, string? knowledgeRootId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 206 | public Task<string> CodebaseIndexStatusAsync(string? workspacePath, string? solutionPath, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 207 | public Task<string> CodebaseIndexSearchAsync(string? workspacePath, string? solutionPath, string query, int topN, string? pathPrefix, IReadOnlyList<string>? excludePathPrefixes, IReadOnlyList<string>? extensions, bool semantic, double alpha, double beta, int vecTopK, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 208 | public Task<string> CodebaseIndexExplainAsync(string? workspacePath, string? solutionPath, long hitId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 209 | public Task<string> CodebaseIndexReindexAsync(string? workspacePath, string? solutionPath, bool fullRebuild, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 210 | public Task<string> SelectChatMessageAsync(int index) => throw new NotImplementedException(); |
| 211 | public Task<string> SelectChatMessageByOrdinalAsync(int ordinal, int endOrdinal) => throw new NotImplementedException(); |
| 212 | public Task<string> GetSelectedChatMessageAsync() => throw new NotImplementedException(); |
| 213 | public Task<string> FindIntercomMessagesForCodeAsync(IReadOnlyDictionary<string, JsonElement>? args) => |
| 214 | throw new NotImplementedException(); |
| 215 | public Task<string> RelateIntercomMessageRangeToCodeAsync(IReadOnlyDictionary<string, JsonElement>? args) => |
| 216 | throw new NotImplementedException(); |
| 217 | public Task<string> EditChatAssistantMessageAsync(string messageId, string newContent, string? reason = null) => throw new NotImplementedException(); |
| 218 | public Task<string> ExportChatReadableAsync(bool writeFile = false, string? fileName = null) => throw new NotImplementedException(); |
| 219 | public Task<string> IdeAgentVerifyAsync(string? policy, string? sandboxProfile, string? solutionPath, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 220 | public Task<string> IdeAgentVerifyBatchAsync(string? policy, string? sandboxProfile, string? solutionPath, bool useWorktree, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 221 | public Task<string> IdeAgentCancelAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 222 | public Task<string> IdeAgentStatusAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 223 | public Task<string> IdeAgentLastAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 224 | public Task<string> IdeAgentSandboxPrepareAsync(string? profile, string? workspaceRoot, CancellationToken cancellationToken = default) => throw new NotImplementedException(); |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | |