261 lines
8.5 KiB
Python
261 lines
8.5 KiB
Python
"""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()
|