diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index 6e0d354..3222adc 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -60,3 +60,4 @@ 3. hook 실패/예외는 non-blocking(권한 흐름 지속). 4. `additionalContext`는 가능한 경로에서 메시지 컨텍스트로 반영. +- 2026-04-04(추가): `/mcp add/remove/reset` 확장, `tool_search` 기반 복구 프롬프트 강화, 슬래시 힌트 밀도(`rich/balanced/simple`) 연동. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d6b5071..6cd503f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2723,3 +2723,28 @@ else: - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과. - 기준 시점 전체 테스트: 403 passed, 0 failed. + +## 2026-04-04 추가 진행 기록 (연속 실행 2차) + +### A. /mcp 명령 확장 +- `/mcp add :: stdio [args...]` 지원. +- `/mcp add :: sse ` 지원. +- `/mcp remove ` 지원. +- `/mcp reset` 지원 (세션 오버라이드 초기화). +- 상태/도움말 문구를 확장 명령 기준으로 업데이트. + +### B. Agentic loop 복구 가이드 강화 +- 미등록/비허용 도구 복구 프롬프트에 `tool_search` 우선 사용 지침 추가. +- 반복 실패 중단 응답에도 `tool_search` 기반 재시도 루트를 명시. + +### C. UI/UX 단순화(슬래시 팝업 힌트) +- `agentUiExpressionLevel`(`rich|balanced|simple`)에 따라 슬래시 팝업 힌트 밀도 조정. +- simple: 최소 정보, rich: 추천 명령 포함, balanced: 기본 정보. + +### D. 설정값-실동작 점검(핵심) +- `MaxRetryOnError`, `EnableProactiveContextCompact`, `ContextCompactTriggerPercent`, `MaxContextTokens`, `AllowInsecureTls`, `AgentUiExpressionLevel` 항목의 + 모델/뷰모델/UI/런타임 참조 경로를 점검하여 동작 연결을 확인. + +### E. 검증 결과 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (411 passed, 0 failed). diff --git a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs index d72bb87..de4a688 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs @@ -399,6 +399,24 @@ public class AgentLoopCodeQualityTests guidance.Should().Contain("NRE"); } + [Fact] + public void UnknownAndDisallowedRecoveryPrompts_ShouldGuideToolSearch() + { + var unknownPrompt = InvokePrivateStatic( + "BuildUnknownToolRecoveryPrompt", + "read_file_typo", + new[] { "file_read", "file_edit", "tool_search" }); + + var disallowedPrompt = InvokePrivateStatic( + "BuildDisallowedToolRecoveryPrompt", + "shell_exec", + new HashSet(StringComparer.OrdinalIgnoreCase) { "file_read", "tool_search" }, + new[] { "file_read", "tool_search" }); + + unknownPrompt.Should().Contain("tool_search"); + disallowedPrompt.Should().Contain("tool_search"); + } + [Fact] public void IsFailurePatternForTaskType_MatchesCaseInsensitiveTaskToken() { @@ -1756,6 +1774,28 @@ public class AgentLoopCodeQualityTests resolved.Should().Be("Write"); } + [Fact] + public void ResolveRequestedToolName_MapsRgAliasToGrep() + { + var resolved = InvokePrivateStatic( + "ResolveRequestedToolName", + "rg", + new List { "file_read", "grep", "glob" }); + + resolved.Should().Be("grep"); + } + + [Fact] + public void ResolveRequestedToolName_MapsCodeSearchAliasToSearchCodebase() + { + var resolved = InvokePrivateStatic( + "ResolveRequestedToolName", + "code_search", + new List { "search_codebase", "file_read" }); + + resolved.Should().Be("search_codebase"); + } + [Fact] public void ShouldRunPostToolVerification_MatchesTabAndToolTypeRules() { diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs index 26c0d0a..c107ca9 100644 --- a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs +++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs @@ -21,6 +21,9 @@ public class ChatWindowSlashPolicyTests [InlineData("/mcp", "status", "")] [InlineData("enable all", "enable", "all")] [InlineData("reconnect chrome", "reconnect", "chrome")] + [InlineData("add chrome :: stdio node server.js", "add", "chrome :: stdio node server.js")] + [InlineData("remove all", "remove", "all")] + [InlineData("reset", "reset", "")] [InlineData("status", "status", "")] public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget) { @@ -74,4 +77,46 @@ public class ChatWindowSlashPolicyTests files.Count.Should().Be(expectedFileCount); message.Should().Be(expectedMessage); } + + [Fact] + public void ParseMcpAddTarget_ShouldParseStdioEntry() + { + var (success, entry, error) = ChatWindow.ParseMcpAddTarget("chrome :: stdio node server.js --port 3000"); + + success.Should().BeTrue(error); + entry.Should().NotBeNull(); + entry!.Name.Should().Be("chrome"); + entry.Transport.Should().Be("stdio"); + entry.Command.Should().Be("node"); + entry.Args.Should().ContainInOrder("server.js", "--port", "3000"); + } + + [Fact] + public void ParseMcpAddTarget_ShouldParseSseEntry() + { + var (success, entry, error) = ChatWindow.ParseMcpAddTarget("internal-sse :: sse https://intra.example.local/mcp/sse"); + + success.Should().BeTrue(error); + entry.Should().NotBeNull(); + entry!.Name.Should().Be("internal-sse"); + entry.Transport.Should().Be("sse"); + entry.Url.Should().Be("https://intra.example.local/mcp/sse"); + } + + [Fact] + public void ParseMcpAddTarget_ShouldFailWithoutSeparator() + { + var (success, _, error) = ChatWindow.ParseMcpAddTarget("chrome stdio node server.js"); + + success.Should().BeFalse(); + error.Should().Contain("::"); + } + + [Fact] + public void TokenizeCommand_ShouldKeepQuotedSegments() + { + var tokens = ChatWindow.TokenizeCommand("stdio \"C:\\\\Program Files\\\\node.exe\" \"server path.js\" --flag"); + + tokens.Should().ContainInOrder("stdio", "C:\\\\Program Files\\\\node.exe", "server path.js", "--flag"); + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 8c50b3a..cae7a85 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Diagnostics; +using System.Collections.Concurrent; using System.IO; using System.Text.Json; using AxCopilot.Models; @@ -14,10 +15,17 @@ namespace AxCopilot.Services.Agent; public partial class AgentLoopService { internal const string ApprovedPlanDecisionPrefix = "[PLAN_APPROVED_STEPS]"; + public sealed record PermissionPromptPreview( + string Kind, + string Title, + string Summary, + string Content, + string? PreviousContent = null); private readonly LlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; + private readonly ConcurrentDictionary _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase); /// 에이전트 이벤트 스트림 (UI 바인딩용). public ObservableCollection Events { get; } = new(); @@ -66,6 +74,22 @@ public partial class AgentLoopService _settings = settings; } + public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview) + { + preview = null; + var key = BuildPermissionPreviewKey(toolName, target); + if (string.IsNullOrWhiteSpace(key)) + return false; + + if (_pendingPermissionPreviews.TryGetValue(key, out var value)) + { + preview = value; + return true; + } + + return false; + } + /// /// 에이전트 루프를 일시정지합니다. /// 다음 반복 시작 시점에서 대기 상태가 됩니다. @@ -414,7 +438,13 @@ public partial class AgentLoopService // 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비) { var condensed = await ContextCondenser.CondenseIfNeededAsync( - messages, _llm, llm.MaxContextTokens, ct); + messages, + _llm, + llm.MaxContextTokens, + llm.EnableProactiveContextCompact, + llm.ContextCompactTriggerPercent, + false, + ct); if (condensed) EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다"); } @@ -3474,6 +3504,7 @@ public partial class AgentLoopService $"- 실패 도구: {unknownToolName}\n" + aliasHint + $"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" + + "- 도구가 애매하면 먼저 tool_search를 호출해 정확한 이름을 찾으세요.\n" + "위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요."; } @@ -3493,6 +3524,7 @@ public partial class AgentLoopService $"- 요청 도구: {requestedToolName}\n" + policyLine + $"- 지금 사용 가능한 도구 예시: {activePreview}\n" + + "- 도구 선택이 모호하면 tool_search로 허용 가능한 대체 도구를 먼저 찾으세요.\n" + "허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다."; } @@ -3506,7 +3538,7 @@ public partial class AgentLoopService $"- 반복 횟수: {repeatedUnknownToolCount}\n" + $"- 실패 도구: {unknownToolName}\n" + $"- 현재 사용 가능한 도구 예시: {preview}\n" + - "- 다음 실행에서는 위 목록의 실제 도구 이름으로 호출하세요."; + "- 다음 실행에서는 tool_search로 도구명을 확인한 뒤 위 목록의 실제 도구 이름으로 호출하세요."; } private static string BuildDisallowedToolLoopAbortResponse( @@ -3519,7 +3551,7 @@ public partial class AgentLoopService $"- 반복 횟수: {repeatedCount}\n" + $"- 비허용 도구: {toolName}\n" + $"- 현재 사용 가능한 도구 예시: {preview}\n" + - "- 다음 실행에서는 허용된 도구만 호출하도록 계획을 수정하세요."; + "- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요."; } private static readonly Dictionary ToolAliasMap = new(StringComparer.OrdinalIgnoreCase) @@ -3541,6 +3573,10 @@ public partial class AgentLoopService ["listfiles"] = "glob", ["list_files"] = "glob", ["grep"] = "grep", + ["greptool"] = "grep", + ["grep_tool"] = "grep", + ["rg"] = "grep", + ["ripgrep"] = "grep", ["search"] = "grep", ["globfiles"] = "glob", ["glob_files"] = "glob", @@ -3552,8 +3588,13 @@ public partial class AgentLoopService ["listmcpresourcestool"] = "mcp_list_resources", ["readmcpresourcetool"] = "mcp_read_resource", ["agent"] = "spawn_agent", + ["spawnagent"] = "spawn_agent", ["task"] = "spawn_agent", ["sendmessage"] = "notify_tool", + ["shellcommand"] = "process", + ["execute"] = "process", + ["codesearch"] = "search_codebase", + ["code_search"] = "search_codebase", ["powershell"] = "process", ["toolsearch"] = "tool_search", ["todowrite"] = "todo_write", @@ -4006,6 +4047,99 @@ public partial class AgentLoopService return primary; } + private static string BuildPermissionPreviewKey(string toolName, string target) + { + if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) + return ""; + return $"{toolName.Trim()}|{target.Trim()}"; + } + + private static PermissionPromptPreview? BuildPermissionPreview(string toolName, JsonElement input, string target) + { + var normalizedTool = toolName.Trim().ToLowerInvariant(); + + if (normalizedTool.Contains("file_write")) + { + var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String + ? c.GetString() ?? "" + : ""; + var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)"; + string? previous = null; + try + { + if (File.Exists(target)) + { + var original = File.ReadAllText(target); + previous = original.Length <= 2000 ? original : original[..2000] + "\n... (truncated)"; + } + } + catch + { + previous = null; + } + return new PermissionPromptPreview( + Kind: "file_write", + Title: "Pending file write", + Summary: "The tool will replace or create file content.", + Content: truncated, + PreviousContent: previous); + } + + if (normalizedTool.Contains("file_edit")) + { + if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array) + { + var lines = edits.EnumerateArray() + .Take(6) + .Select((edit, index) => + { + var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String + ? oldElem.GetString() ?? "" + : ""; + var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String + ? newElem.GetString() ?? "" + : ""; + + oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "..."; + newText = newText.Length <= 180 ? newText : newText[..180] + "..."; + return $"{index + 1}) - {oldText}\n + {newText}"; + }); + + return new PermissionPromptPreview( + Kind: "file_edit", + Title: "Pending file edit", + Summary: $"The tool requested {edits.GetArrayLength()} edit block(s).", + Content: string.Join("\n", lines)); + } + } + + if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell")) + { + var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String + ? cmd.GetString() ?? target + : target; + return new PermissionPromptPreview( + Kind: "command", + Title: "Pending command", + Summary: "The tool wants to run this command.", + Content: command); + } + + if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http")) + { + var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String + ? u.GetString() ?? target + : target; + return new PermissionPromptPreview( + Kind: "web", + Title: "Pending network access", + Summary: "The tool wants to access this URL.", + Content: url); + } + + return null; + } + private static bool TryGetHookUpdatedInput( IEnumerable results, out JsonElement updatedInput) @@ -4161,7 +4295,22 @@ public partial class AgentLoopService if (PermissionModeCatalog.RequiresUserApproval(effectivePerm)) EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요({effectivePerm}) · 대상: {target}"); - var allowed = await context.CheckToolPermissionAsync(toolName, target); + var previewKey = BuildPermissionPreviewKey(toolName, target); + var preview = BuildPermissionPreview(toolName, input, target); + if (!string.IsNullOrWhiteSpace(previewKey) && preview != null) + _pendingPermissionPreviews[previewKey] = preview; + + bool allowed; + try + { + allowed = await context.CheckToolPermissionAsync(toolName, target); + } + finally + { + if (!string.IsNullOrWhiteSpace(previewKey)) + _pendingPermissionPreviews.TryRemove(previewKey, out _); + } + if (allowed) { if (PermissionModeCatalog.RequiresUserApproval(effectivePerm)) diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index f020533..cc76c7d 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -4901,9 +4901,15 @@ public partial class ChatWindow : Window var end = Math.Min(start + SlashPageSize, total); var totalSkills = _slashAllMatches.Count(x => x.IsSkill); var totalCommands = total - totalSkills; + var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); SlashPopupTitle.Text = "명령 팔레트"; - SlashPopupHint.Text = $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · /compact 지원"; + SlashPopupHint.Text = expressionLevel switch + { + "simple" => $"명령 {totalCommands} · 스킬 {totalSkills}", + "rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · 추천: /compact /mcp /chrome", + _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개", + }; // 위 화살표 if (start > 0) @@ -5523,7 +5529,7 @@ public partial class ChatWindow : Window ("/doctor", "프로젝트/환경 점검 체크를 수행합니다.")); AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg, - ("/mcp", "외부 도구 연결 상태 점검"), + ("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset 관리"), ("/agents", "에이전트 분담 전략 제시"), ("/plugin", "플러그인 구성 점검"), ("/reload-plugins", "플러그인 재로드 점검"), @@ -6177,11 +6183,106 @@ public partial class ChatWindow : Window "enable" => ("enable", target), "disable" => ("disable", target), "reconnect" => ("reconnect", target), + "add" => ("add", target), + "remove" => ("remove", target), + "reset" => ("reset", target), "status" => ("status", target), _ => ("help", text), }; } + internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target) + { + var raw = (target ?? "").Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return (false, null, "사용법: /mcp add <서버명> :: stdio <명령> [인자...] | /mcp add <서버명> :: sse "); + + var sep = raw.IndexOf("::", StringComparison.Ordinal); + if (sep < 0) + return (false, null, "추가 형식이 올바르지 않습니다. 구분자 `::` 를 사용하세요.\n예: /mcp add chrome :: stdio node server.js"); + + var name = raw[..sep].Trim(); + var spec = raw[(sep + 2)..].Trim(); + if (string.IsNullOrWhiteSpace(name)) + return (false, null, "서버명이 비어 있습니다. /mcp add <서버명> :: ... 형식으로 입력하세요."); + if (string.IsNullOrWhiteSpace(spec)) + return (false, null, "연결 정보가 비어 있습니다. stdio 또는 sse 설정을 입력하세요."); + + var tokens = TokenizeCommand(spec); + if (tokens.Count < 2) + return (false, null, "연결 정보가 부족합니다. 예: stdio node server.js 또는 sse https://host/sse"); + + var transport = tokens[0].Trim().ToLowerInvariant(); + if (transport == "stdio") + { + var command = tokens[1].Trim(); + if (string.IsNullOrWhiteSpace(command)) + return (false, null, "stdio 방식은 실행 명령(command)이 필요합니다."); + + var args = tokens.Count > 2 ? tokens.Skip(2).ToList() : new List(); + return (true, new McpServerEntry + { + Name = name, + Transport = "stdio", + Command = command, + Args = args, + Enabled = true, + }, ""); + } + + if (transport == "sse") + { + var url = string.Join(" ", tokens.Skip(1)).Trim(); + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) + return (false, null, $"유효하지 않은 SSE URL 입니다: {url}"); + if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return (false, null, "SSE URL은 http 또는 https 스킴만 지원합니다."); + + return (true, new McpServerEntry + { + Name = name, + Transport = "sse", + Url = url, + Enabled = true, + }, ""); + } + + return (false, null, $"지원하지 않는 transport 입니다: {transport}. 사용 가능 값: stdio, sse"); + } + + internal static List TokenizeCommand(string input) + { + var text = input ?? ""; + var tokens = new List(); + var sb = new System.Text.StringBuilder(); + var inQuote = false; + + foreach (var ch in text) + { + if (ch == '"') + { + inQuote = !inQuote; + continue; + } + + if (!inQuote && char.IsWhiteSpace(ch)) + { + if (sb.Length == 0) continue; + tokens.Add(sb.ToString()); + sb.Clear(); + continue; + } + + sb.Append(ch); + } + + if (sb.Length > 0) + tokens.Add(sb.ToString()); + + return tokens; + } + private async Task BuildMcpRuntimeStatusTextAsync( IEnumerable? source = null, bool runtimeCheck = true, @@ -6235,7 +6336,7 @@ public partial class ChatWindow : Window lines.Add($"- {name} [{transport}] : {statusLabel}"); } - lines.Add("명령: /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all> (enable/disable는 세션 한정)"); + lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset"); return string.Join("\n", lines); } @@ -6275,17 +6376,66 @@ public partial class ChatWindow : Window private async Task HandleMcpSlashAsync(string displayText, CancellationToken ct = default) { var llm = _settings.Settings.Llm; - var servers = llm.McpServers ?? []; + llm.McpServers ??= new List(); + var servers = llm.McpServers; var (action, target) = ParseMcpAction(displayText); if (action == "help") - return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>"; + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset"; if (action == "status") return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); + if (action == "reset") + { + var changed = _sessionMcpEnabledOverrides.Count; + _sessionMcpEnabledOverrides.Clear(); + return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (action == "add") + { + var (ok, entry, error) = ParseMcpAddTarget(target); + if (!ok || entry == null) + return error; + + var duplicate = servers.Any(s => string.Equals(s.Name, entry.Name, StringComparison.OrdinalIgnoreCase)); + if (duplicate) + return $"동일한 이름의 MCP 서버가 이미 존재합니다: {entry.Name}\n기존 항목을 수정하거나 /mcp remove {entry.Name} 후 다시 추가하세요."; + + servers.Add(entry); + _settings.Save(); + _sessionMcpEnabledOverrides.Remove(entry.Name); + + return $"MCP 서버를 추가했습니다: {entry.Name} ({entry.Transport})\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + if (servers.Count == 0) return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요."; + if (action == "remove") + { + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + var removed = servers.Count; + servers.Clear(); + _sessionMcpEnabledOverrides.Clear(); + _settings.Save(); + return $"MCP 서버를 모두 제거했습니다. ({removed}개)"; + } + + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"제거 대상 서버를 찾지 못했습니다: {target}"; + + var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)); + _sessionMcpEnabledOverrides.Remove(resolved); + _settings.Save(); + return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + if (action is "enable" or "disable") { var newEnabled = action == "enable"; @@ -6335,7 +6485,7 @@ public partial class ChatWindow : Window return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false); } - return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>"; + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset"; } private string BuildSlashHeapDumpText()