| 1 | using System.Text.Json; |
| 2 | using ModelContextProtocol.Protocol; |
| 3 | using ModelContextProtocol.Server; |
| 4 | |
| 5 | namespace CascadeIDE.Services; |
| 6 | |
| 7 | public static class IdeMcpServer |
| 8 | { |
| 9 | public static McpServerOptions BuildOptions(IIdeMcpActions actions) |
| 10 | { |
| 11 | bool includeDebugTools = false; |
| 12 | #if DEBUG |
| 13 | includeDebugTools = true; |
| 14 | #endif |
| 15 | var toolsList = IdeMcpToolCatalog.BuildTools(includeDebugTools); |
| 16 | |
| 17 | return new McpServerOptions |
| 18 | { |
| 19 | ServerInfo = new Implementation { Name = "CascadeIDE", Version = "0.1.0" }, |
| 20 | ProtocolVersion = "2024-11-05", |
| 21 | Capabilities = new ServerCapabilities { Tools = new ToolsCapability { ListChanged = false } }, |
| 22 | Handlers = new McpServerHandlers |
| 23 | { |
| 24 | ListToolsHandler = (_, _) => ValueTask.FromResult(new ListToolsResult { Tools = toolsList }), |
| 25 | CallToolHandler = async (request, cancellationToken) => |
| 26 | { |
| 27 | var name = request.Params?.Name ?? ""; |
| 28 | var args = request.Params?.Arguments is IReadOnlyDictionary<string, JsonElement> a ? a : null; |
| 29 | try |
| 30 | { |
| 31 | var text = await CallToolByConventionAsync(actions, name, args, cancellationToken).ConfigureAwait(false); |
| 32 | // Тулы-действия при ошибке возвращают текст (не "OK") — помечаем как IsError, чтобы агент видел сбой. |
| 33 | bool isError; |
| 34 | if (name == "ide_execute_command") |
| 35 | isError = text.StartsWith("Missing", StringComparison.Ordinal) || text.StartsWith("Unknown command", StringComparison.Ordinal) || text.StartsWith("Error", StringComparison.Ordinal); |
| 36 | else if (string.Equals(name, "ide_create_blank_solution", StringComparison.Ordinal)) |
| 37 | { |
| 38 | // Успех: «OK:» + абсолютный путь к .sln; иначе — текст ошибки (в т.ч. на русском). |
| 39 | isError = !text.StartsWith("OK:", StringComparison.Ordinal); |
| 40 | } |
| 41 | else |
| 42 | { |
| 43 | var isActionTool = name is "ide_open_file" or "ide_load_solution" or "ide_select" or "ide_set_breakpoint" or "ide_remove_breakpoint" |
| 44 | or "ide_show_preview" or "ide_show_editor_preview" or "ide_apply_edit" or "ide_go_to_position" or "ide_reveal_editor_range" or "ide_intercom_reveal_attachment" or "ide_focus_editor" |
| 45 | or "ide_set_ui_theme" or "ide_set_control_layout" or "ide_set_control_text" or "ide_click_control" |
| 46 | or "ide_send_keys" or "ide_set_focus" or "ide_highlight_control" or "ide_set_panel_size" or "ide_add_control" |
| 47 | or "ide_write_agent_notes" |
| 48 | or "ide_run_code_cleanup" or "ide_git_commit" or "ide_git_push" |
| 49 | or "ide_git_log" or "ide_git_fetch" or "ide_git_pull" or "ide_git_branch" or "ide_git_show" or "ide_git_submodule"; |
| 50 | isError = isActionTool && text != "OK"; |
| 51 | } |
| 52 | return new CallToolResult { Content = [new TextContentBlock { Text = text }], IsError = isError }; |
| 53 | } |
| 54 | catch (Exception ex) |
| 55 | { |
| 56 | if (ex.Message.Contains("invalid thread", StringComparison.OrdinalIgnoreCase)) |
| 57 | { |
| 58 | try |
| 59 | { |
| 60 | var dir = AppContext.BaseDirectory; |
| 61 | var logPath = Path.Combine(dir, "invalid-thread-log.txt"); |
| 62 | File.WriteAllText(logPath, ex.ToString()); |
| 63 | } |
| 64 | catch { /* ignore */ } |
| 65 | #if DEBUG |
| 66 | var stack = ex.StackTrace ?? ""; |
| 67 | return new CallToolResult { Content = [new TextContentBlock { Text = "Error: " + ex.Message + " [caught in IdeMcpServer]\n\n" + stack }], IsError = true }; |
| 68 | #endif |
| 69 | } |
| 70 | return new CallToolResult { Content = [new TextContentBlock { Text = "Error: " + ex.Message }], IsError = true }; |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | private static async Task<string> CallToolByConventionAsync( |
| 78 | IIdeMcpActions actions, |
| 79 | string toolName, |
| 80 | IReadOnlyDictionary<string, JsonElement>? args, |
| 81 | CancellationToken cancellationToken) |
| 82 | { |
| 83 | // Special case: dispatcher tool supports arbitrary command ids and nested args object. |
| 84 | if (toolName == "ide_execute_command") |
| 85 | return await CallExecuteCommand(actions, args, cancellationToken).ConfigureAwait(false); |
| 86 | |
| 87 | if (!toolName.StartsWith("ide_", StringComparison.Ordinal)) |
| 88 | return $"Unknown tool: {toolName}"; |
| 89 | |
| 90 | // Most proxy tools map 1:1: |
| 91 | // tool ide_open_file -> command_id open_file |
| 92 | // tool ide_get_ui_theme -> command_id get_ui_theme |
| 93 | // and so on. |
| 94 | if (!IdeMcpToolNaming.TryToCommandId(toolName, out var commandId)) |
| 95 | return $"Unknown tool: {toolName}"; |
| 96 | |
| 97 | // Compatibility overrides (tool name stays stable; backend command id may evolve). |
| 98 | if (string.Equals(commandId, IdeCommands.Build, StringComparison.Ordinal)) |
| 99 | commandId = IdeCommands.BuildStructured; |
| 100 | |
| 101 | return await actions.ExecuteCommandAsync(commandId, args, cancellationToken).ConfigureAwait(false); |
| 102 | } |
| 103 | |
| 104 | private static async Task<string> CallExecuteCommand(IIdeMcpActions actions, IReadOnlyDictionary<string, JsonElement>? args, CancellationToken cancellationToken) |
| 105 | { |
| 106 | var merged = IdeExecuteCommandArgs.MergeNestedArgs(args); |
| 107 | var commandId = merged is not null && merged.TryGetValue("command_id", out var cid) ? cid.GetString() : null; |
| 108 | if (string.IsNullOrEmpty(commandId)) |
| 109 | return "Missing command_id"; |
| 110 | return await actions.ExecuteCommandAsync(commandId, merged, cancellationToken); |
| 111 | } |
| 112 | |
| 113 | } |
| 114 | |