diff --git a/blackduck/mcp_server.py b/blackduck/mcp_server.py index 48ba55f1..23911d9e 100644 --- a/blackduck/mcp_server.py +++ b/blackduck/mcp_server.py @@ -284,18 +284,20 @@ def get_project_vulnerabilities(project_name: str, version_name: Optional[str] = for i, vuln in enumerate(vulnerabilities): if limit and i >= limit: break - + + # BlackDuck's default vulnerable-components response nests + # CVE details under 'vulnerability', not at the top level. + detail = vuln.get('vulnerability') or {} + result.append({ 'componentName': vuln.get('componentName'), 'componentVersionName': vuln.get('componentVersionName'), - 'vulnerabilityName': vuln.get('vulnerabilityName'), - 'severity': vuln.get('severity'), - 'baseScore': vuln.get('baseScore'), - 'overallScore': vuln.get('overallScore'), - 'remediationStatus': vuln.get('remediationStatus'), - 'description': vuln.get('description', ''), - 'publishedDate': vuln.get('publishedDate'), - 'updatedDate': vuln.get('updatedDate') + 'vulnerabilityName': detail.get('vulnerabilityId'), + 'severity': detail.get('severity'), + 'remediationStatus': detail.get('remediationStatus'), + 'description': detail.get('description', ''), + 'source': detail.get('source'), + 'cweIds': detail.get('cweIds', []), }) return result diff --git a/test/test_mcp_server_vulnerabilities.py b/test/test_mcp_server_vulnerabilities.py new file mode 100644 index 00000000..e18e4ade --- /dev/null +++ b/test/test_mcp_server_vulnerabilities.py @@ -0,0 +1,90 @@ +import asyncio +from unittest.mock import patch + +import pytest + +# fastmcp is an optional 'mcp' extra, not part of the base test lockfile. +# Skip this module entirely rather than failing CI when it isn't installed. +pytest.importorskip("fastmcp") + +from blackduck.mcp_server import BlackDuckMCPServer + + +@pytest.fixture +def mcp_server(monkeypatch): + monkeypatch.setenv("BLACKDUCK_URL", "https://fake-hub-host") + monkeypatch.setenv("BLACKDUCK_TOKEN", "fake-token") + with patch("blackduck.mcp_server.Client") as mock_client_cls: + server = BlackDuckMCPServer() + yield server, mock_client_cls.return_value + + +def _call_tool(server, name, **kwargs): + async def _run(): + tool = await server.mcp.get_tool(name) + return tool.fn(**kwargs) + return asyncio.run(_run()) + + +def _fake_get_resource(vulnerable_component): + def get_resource(name, parent=None, **kwargs): + if name == "projects": + return iter([{"name": "Acme", "_meta": {"href": "https://fake-hub-host/api/projects/p1"}}]) + if name == "versions": + return iter([{"versionName": "1.0", "_meta": {"href": "https://fake-hub-host/api/projects/p1/versions/v1"}}]) + if name == "vulnerable-components": + return iter([vulnerable_component]) + raise AssertionError(f"unexpected resource name: {name}") + return get_resource + + +def test_get_project_vulnerabilities_reads_nested_vulnerability_object(mcp_server): + """Regression test: BlackDuck's vulnerable-components response nests CVE + details under a 'vulnerability' sub-object, not at the top level.""" + server, mock_client = mcp_server + mock_client.get_resource.side_effect = _fake_get_resource({ + "componentName": "ag-grid-community", + "componentVersionName": "24.0.0", + "vulnerability": { + "vulnerabilityId": "CVE-2024-38996", + "severity": "CRITICAL", + "description": "Prototype pollution via _.mergeDeep", + "source": "NVD", + "cweIds": ["CWE-1321"], + "remediationStatus": "NEW", + }, + }) + + result = _call_tool(server, "get_project_vulnerabilities", project_name="Acme") + + assert result == [{ + "componentName": "ag-grid-community", + "componentVersionName": "24.0.0", + "vulnerabilityName": "CVE-2024-38996", + "severity": "CRITICAL", + "remediationStatus": "NEW", + "description": "Prototype pollution via _.mergeDeep", + "source": "NVD", + "cweIds": ["CWE-1321"], + }] + + +def test_get_project_vulnerabilities_handles_missing_vulnerability_object(mcp_server): + server, mock_client = mcp_server + mock_client.get_resource.side_effect = _fake_get_resource({ + "componentName": "some-lib", + "componentVersionName": "1.0.0", + }) + + result = _call_tool(server, "get_project_vulnerabilities", project_name="Acme") + + assert result == [{ + "componentName": "some-lib", + "componentVersionName": "1.0.0", + "vulnerabilityName": None, + "severity": None, + "remediationStatus": None, + "description": "", + "source": None, + "cweIds": [], + }]