test_mcp_tools_config.py
1 """Tests for MCP tools interactive configuration in hermes_cli.tools_config.""" 2 3 from types import SimpleNamespace 4 from unittest.mock import MagicMock, patch 5 6 from hermes_cli.tools_config import _configure_mcp_tools_interactive 7 8 # Patch targets: imports happen inside the function body, so patch at source 9 _PROBE = "tools.mcp_tool.probe_mcp_server_tools" 10 _CHECKLIST = "hermes_cli.curses_ui.curses_checklist" 11 _SAVE = "hermes_cli.tools_config.save_config" 12 13 14 def test_no_mcp_servers_prints_info(capsys): 15 """Returns immediately when no MCP servers are configured.""" 16 config = {} 17 _configure_mcp_tools_interactive(config) 18 captured = capsys.readouterr() 19 assert "No MCP servers configured" in captured.out 20 21 22 def test_all_servers_disabled_prints_info(capsys): 23 """Returns immediately when all configured servers have enabled=false.""" 24 config = { 25 "mcp_servers": { 26 "github": {"command": "npx", "enabled": False}, 27 "slack": {"command": "npx", "enabled": "false"}, 28 } 29 } 30 _configure_mcp_tools_interactive(config) 31 captured = capsys.readouterr() 32 assert "disabled" in captured.out 33 34 35 def test_probe_failure_shows_warning(capsys): 36 """Shows warning when probe returns no tools.""" 37 config = {"mcp_servers": {"github": {"command": "npx"}}} 38 with patch(_PROBE, return_value={}): 39 _configure_mcp_tools_interactive(config) 40 captured = capsys.readouterr() 41 assert "Could not discover" in captured.out 42 43 44 def test_probe_exception_shows_error(capsys): 45 """Shows error when probe raises an exception.""" 46 config = {"mcp_servers": {"github": {"command": "npx"}}} 47 with patch(_PROBE, side_effect=RuntimeError("MCP not installed")): 48 _configure_mcp_tools_interactive(config) 49 captured = capsys.readouterr() 50 assert "Failed to probe" in captured.out 51 52 53 def test_no_changes_when_checklist_cancelled(capsys): 54 """No config changes when user cancels (ESC) the checklist.""" 55 config = { 56 "mcp_servers": { 57 "github": {"command": "npx", "args": ["-y", "server-github"]}, 58 } 59 } 60 tools = [("create_issue", "Create an issue"), ("search_repos", "Search repos")] 61 62 with patch(_PROBE, return_value={"github": tools}), \ 63 patch(_CHECKLIST, return_value={0, 1}), \ 64 patch(_SAVE) as mock_save: 65 _configure_mcp_tools_interactive(config) 66 mock_save.assert_not_called() 67 captured = capsys.readouterr() 68 assert "no changes" in captured.out.lower() 69 70 71 def test_disabling_tool_writes_exclude_list(capsys): 72 """Unchecking a tool adds it to the exclude list.""" 73 config = { 74 "mcp_servers": { 75 "github": {"command": "npx"}, 76 } 77 } 78 tools = [ 79 ("create_issue", "Create an issue"), 80 ("delete_repo", "Delete a repo"), 81 ("search_repos", "Search repos"), 82 ] 83 84 # User unchecks delete_repo (index 1) 85 with patch(_PROBE, return_value={"github": tools}), \ 86 patch(_CHECKLIST, return_value={0, 2}), \ 87 patch(_SAVE) as mock_save: 88 _configure_mcp_tools_interactive(config) 89 90 mock_save.assert_called_once() 91 tools_cfg = config["mcp_servers"]["github"]["tools"] 92 assert tools_cfg["exclude"] == ["delete_repo"] 93 assert "include" not in tools_cfg 94 95 96 def test_enabling_all_clears_filters(capsys): 97 """Checking all tools clears both include and exclude lists.""" 98 config = { 99 "mcp_servers": { 100 "github": { 101 "command": "npx", 102 "tools": {"exclude": ["delete_repo"], "include": ["create_issue"]}, 103 }, 104 } 105 } 106 tools = [("create_issue", "Create"), ("delete_repo", "Delete")] 107 108 # User checks all tools — pre_selected would be {0} (include mode), 109 # so returning {0, 1} is a change 110 with patch(_PROBE, return_value={"github": tools}), \ 111 patch(_CHECKLIST, return_value={0, 1}), \ 112 patch(_SAVE) as mock_save: 113 _configure_mcp_tools_interactive(config) 114 115 mock_save.assert_called_once() 116 tools_cfg = config["mcp_servers"]["github"]["tools"] 117 assert "exclude" not in tools_cfg 118 assert "include" not in tools_cfg 119 120 121 def test_pre_selection_respects_existing_exclude(capsys): 122 """Tools in exclude list start unchecked.""" 123 config = { 124 "mcp_servers": { 125 "github": { 126 "command": "npx", 127 "tools": {"exclude": ["delete_repo"]}, 128 }, 129 } 130 } 131 tools = [("create_issue", "Create"), ("delete_repo", "Delete"), ("search", "Search")] 132 captured_pre_selected = {} 133 134 def fake_checklist(title, labels, pre_selected, **kwargs): 135 captured_pre_selected["value"] = set(pre_selected) 136 return pre_selected # No changes 137 138 with patch(_PROBE, return_value={"github": tools}), \ 139 patch(_CHECKLIST, side_effect=fake_checklist), \ 140 patch(_SAVE): 141 _configure_mcp_tools_interactive(config) 142 143 # create_issue (0) and search (2) should be pre-selected, delete_repo (1) should not 144 assert captured_pre_selected["value"] == {0, 2} 145 146 147 def test_pre_selection_respects_existing_include(capsys): 148 """Only tools in include list start checked.""" 149 config = { 150 "mcp_servers": { 151 "github": { 152 "command": "npx", 153 "tools": {"include": ["search"]}, 154 }, 155 } 156 } 157 tools = [("create_issue", "Create"), ("delete_repo", "Delete"), ("search", "Search")] 158 captured_pre_selected = {} 159 160 def fake_checklist(title, labels, pre_selected, **kwargs): 161 captured_pre_selected["value"] = set(pre_selected) 162 return pre_selected # No changes 163 164 with patch(_PROBE, return_value={"github": tools}), \ 165 patch(_CHECKLIST, side_effect=fake_checklist), \ 166 patch(_SAVE): 167 _configure_mcp_tools_interactive(config) 168 169 # Only search (2) should be pre-selected 170 assert captured_pre_selected["value"] == {2} 171 172 173 def test_multiple_servers_each_get_checklist(capsys): 174 """Each server gets its own checklist.""" 175 config = { 176 "mcp_servers": { 177 "github": {"command": "npx"}, 178 "slack": {"url": "https://mcp.example.com"}, 179 } 180 } 181 checklist_calls = [] 182 183 def fake_checklist(title, labels, pre_selected, **kwargs): 184 checklist_calls.append(title) 185 return pre_selected # No changes 186 187 with patch( 188 _PROBE, 189 return_value={ 190 "github": [("create_issue", "Create")], 191 "slack": [("send_message", "Send")], 192 }, 193 ), patch(_CHECKLIST, side_effect=fake_checklist), \ 194 patch(_SAVE): 195 _configure_mcp_tools_interactive(config) 196 197 assert len(checklist_calls) == 2 198 assert any("github" in t for t in checklist_calls) 199 assert any("slack" in t for t in checklist_calls) 200 201 202 def test_failed_server_shows_warning(capsys): 203 """Servers that fail to connect show warnings.""" 204 config = { 205 "mcp_servers": { 206 "github": {"command": "npx"}, 207 "broken": {"command": "nonexistent"}, 208 } 209 } 210 211 # Only github succeeds 212 with patch( 213 _PROBE, return_value={"github": [("create_issue", "Create")]}, 214 ), patch(_CHECKLIST, return_value={0}), \ 215 patch(_SAVE): 216 _configure_mcp_tools_interactive(config) 217 218 captured = capsys.readouterr() 219 assert "broken" in captured.out 220 221 222 def test_description_truncation_in_labels(): 223 """Long descriptions are truncated in checklist labels.""" 224 config = { 225 "mcp_servers": { 226 "github": {"command": "npx"}, 227 } 228 } 229 long_desc = "A" * 100 230 captured_labels = {} 231 232 def fake_checklist(title, labels, pre_selected, **kwargs): 233 captured_labels["value"] = labels 234 return pre_selected 235 236 with patch( 237 _PROBE, return_value={"github": [("my_tool", long_desc)]}, 238 ), patch(_CHECKLIST, side_effect=fake_checklist), \ 239 patch(_SAVE): 240 _configure_mcp_tools_interactive(config) 241 242 label = captured_labels["value"][0] 243 assert "..." in label 244 assert len(label) < len(long_desc) + 30 # truncated + tool name + parens 245 246 247 def test_switching_from_include_to_exclude(capsys): 248 """When user modifies selection, include list is replaced by exclude list.""" 249 config = { 250 "mcp_servers": { 251 "github": { 252 "command": "npx", 253 "tools": {"include": ["create_issue"]}, 254 }, 255 } 256 } 257 tools = [("create_issue", "Create"), ("search", "Search"), ("delete", "Delete")] 258 259 # User selects create_issue and search (deselects delete) 260 # pre_selected would be {0} (only create_issue from include), so {0, 1} is a change 261 with patch(_PROBE, return_value={"github": tools}), \ 262 patch(_CHECKLIST, return_value={0, 1}), \ 263 patch(_SAVE): 264 _configure_mcp_tools_interactive(config) 265 266 tools_cfg = config["mcp_servers"]["github"]["tools"] 267 assert tools_cfg["exclude"] == ["delete"] 268 assert "include" not in tools_cfg 269 270 271 def test_empty_tools_server_skipped(capsys): 272 """Server with no tools shows info message and skips checklist.""" 273 config = { 274 "mcp_servers": { 275 "empty": {"command": "npx"}, 276 } 277 } 278 checklist_calls = [] 279 280 def fake_checklist(title, labels, pre_selected, **kwargs): 281 checklist_calls.append(title) 282 return pre_selected 283 284 with patch(_PROBE, return_value={"empty": []}), \ 285 patch(_CHECKLIST, side_effect=fake_checklist), \ 286 patch(_SAVE): 287 _configure_mcp_tools_interactive(config) 288 289 assert len(checklist_calls) == 0 290 captured = capsys.readouterr() 291 assert "no tools found" in captured.out