/ tests / hermes_cli / test_mcp_tools_config.py
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