"""Tests for MCP discoverability validation (Issue #155).""" import os import sys import json import tempfile import unittest from unittest.mock import patch, MagicMock sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) from mcp_discoverability import ( validate_mcp_client_config, RELOAD_INSTRUCTIONS, EXPECTED_JENKINS_TOOLS, EXPECTED_GLITCHTIP_TOOLS, ) class TestMcpDiscoverability(unittest.TestCase): def setUp(self): self.tmp_dir = tempfile.TemporaryDirectory() self.gitea_config_path = os.path.join(self.tmp_dir.name, "gitea-mcp.json") self.client_config_path = os.path.join(self.tmp_dir.name, "claude_desktop_config.json") def tearDown(self): self.tmp_dir.cleanup() def _write_json(self, path, data): with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh) def _v2_gitea_config(self, jenkins_enabled=True, glitchtip_enabled=False): return { "version": 2, "contexts": { "prod": { "enabled": True, "services": { "jenkins": { "enabled": jenkins_enabled, "kind": "jenkins", "capabilities": ["read"] }, "glitchtip": { "enabled": glitchtip_enabled, "kind": "glitchtip", "capabilities": ["read"] } } } } } def test_static_validation_success(self): g_cfg = self._v2_gitea_config(jenkins_enabled=True, glitchtip_enabled=True) self._write_json(self.gitea_config_path, g_cfg) c_cfg = { "mcpServers": { "jenkins-mcp": { "command": "python3", "args": ["-m", "jenkins_mcp"], "env": { "JENKINS_MCP_PROFILE": "jenkins-readonly", "JENKINS_MCP_CONFIG": "/path/to/config.json" } }, "glitchtip-mcp": { "command": "python3", "args": ["-m", "glitchtip_mcp"], "env": { "GLITCHTIP_MCP_PROFILE": "glitchtip-readonly", "GLITCHTIP_MCP_CONFIG": "/path/to/config.json" } } } } self._write_json(self.client_config_path, c_cfg) res = validate_mcp_client_config( client_config_path=self.client_config_path, gitea_config_path=self.gitea_config_path, live=False ) self.assertTrue(res) def test_stale_server_name_rejected(self): g_cfg = self._v2_gitea_config(jenkins_enabled=True) self._write_json(self.gitea_config_path, g_cfg) # Uses stale key in client config c_cfg = { "mcpServers": { "jenkins-readonly": { "command": "python3", "args": ["-m", "jenkins_mcp"], "env": { "JENKINS_MCP_PROFILE": "jenkins-readonly", "JENKINS_MCP_CONFIG": "/path/to/config.json" } } } } self._write_json(self.client_config_path, c_cfg) res = validate_mcp_client_config( client_config_path=self.client_config_path, gitea_config_path=self.gitea_config_path, live=False ) self.assertFalse(res) def test_incorrect_arguments_rejected(self): g_cfg = self._v2_gitea_config(jenkins_enabled=True) self._write_json(self.gitea_config_path, g_cfg) # Missing -m or wrong module c_cfg = { "mcpServers": { "jenkins-mcp": { "command": "python3", "args": ["wrong_runner.py"], "env": { "JENKINS_MCP_PROFILE": "jenkins-readonly", "JENKINS_MCP_CONFIG": "/path/to/config.json" } } } } self._write_json(self.client_config_path, c_cfg) res = validate_mcp_client_config( client_config_path=self.client_config_path, gitea_config_path=self.gitea_config_path, live=False ) self.assertFalse(res) def test_missing_required_env_rejected(self): g_cfg = self._v2_gitea_config(jenkins_enabled=True) self._write_json(self.gitea_config_path, g_cfg) # Missing JENKINS_MCP_PROFILE env variable c_cfg = { "mcpServers": { "jenkins-mcp": { "command": "python3", "args": ["-m", "jenkins_mcp"], "env": { "JENKINS_MCP_CONFIG": "/path/to/config.json" } } } } self._write_json(self.client_config_path, c_cfg) res = validate_mcp_client_config( client_config_path=self.client_config_path, gitea_config_path=self.gitea_config_path, live=False ) self.assertFalse(res) @patch("subprocess.Popen") def test_live_check_verification_success(self, mock_popen): g_cfg = self._v2_gitea_config(jenkins_enabled=True) self._write_json(self.gitea_config_path, g_cfg) c_cfg = { "mcpServers": { "jenkins-mcp": { "command": "python3", "args": ["-m", "jenkins_mcp"], "env": { "JENKINS_MCP_PROFILE": "jenkins-readonly", "JENKINS_MCP_CONFIG": "/path/to/config.json" } } } } self._write_json(self.client_config_path, c_cfg) # Mock stdout lines for JSON-RPC mock_proc = MagicMock() mock_popen.return_value = mock_proc init_resp = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05"}}) tools_resp = json.dumps({ "jsonrpc": "2.0", "id": 2, "result": { "tools": [{"name": tool} for tool in EXPECTED_JENKINS_TOOLS] } }) mock_proc.stdout.readline.side_effect = [ init_resp + "\n", tools_resp + "\n", "" ] res = validate_mcp_client_config( client_config_path=self.client_config_path, gitea_config_path=self.gitea_config_path, live=True ) self.assertTrue(res) @patch("subprocess.Popen") def test_live_check_empty_toolset_rejected(self, mock_popen): g_cfg = self._v2_gitea_config(jenkins_enabled=True) self._write_json(self.gitea_config_path, g_cfg) c_cfg = { "mcpServers": { "jenkins-mcp": { "command": "python3", "args": ["-m", "jenkins_mcp"], "env": { "JENKINS_MCP_PROFILE": "jenkins-readonly", "JENKINS_MCP_CONFIG": "/path/to/config.json" } } } } self._write_json(self.client_config_path, c_cfg) mock_proc = MagicMock() mock_popen.return_value = mock_proc init_resp = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}) tools_resp = json.dumps({ "jsonrpc": "2.0", "id": 2, "result": { "tools": [] # empty tool list } }) mock_proc.stdout.readline.side_effect = [ init_resp + "\n", tools_resp + "\n", "" ] # Should return False (coverage fails) when no tools are exposed res = validate_mcp_client_config( client_config_path=self.client_config_path, gitea_config_path=self.gitea_config_path, live=True ) self.assertFalse(res) def test_runbook_instructions_content(self): self.assertIn("MCP CLIENT RELOAD/RECONNECT RUNBOOK", RELOAD_INSTRUCTIONS) self.assertIn("Codex", RELOAD_INSTRUCTIONS) self.assertIn("Gemini", RELOAD_INSTRUCTIONS) self.assertIn("Claude Desktop", RELOAD_INSTRUCTIONS) if __name__ == "__main__": unittest.main()