bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
367 lines
12 KiB
Python
367 lines
12 KiB
Python
# Copyright 2025 Collate
|
|
# Licensed under the Collate Community License, Version 1.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""
|
|
Unit tests for MCP client module
|
|
"""
|
|
|
|
import json
|
|
import tempfile
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from metadata.ingestion.source.mcp.client import (
|
|
HttpTransport,
|
|
McpClient,
|
|
McpProtocolError,
|
|
McpServerInfo,
|
|
StdioTransport,
|
|
discover_servers_from_config_files,
|
|
parse_claude_desktop_config,
|
|
parse_vscode_config,
|
|
)
|
|
|
|
|
|
class TestMcpServerInfo:
|
|
"""Tests for McpServerInfo dataclass"""
|
|
|
|
def test_default_values(self):
|
|
server = McpServerInfo(name="test-server")
|
|
assert server.name == "test-server"
|
|
assert server.transport == "Stdio"
|
|
assert server.command is None
|
|
assert server.args is None
|
|
assert server.env is None
|
|
assert server.url is None
|
|
assert server.api_key is None
|
|
assert server.tools == []
|
|
assert server.resources == []
|
|
assert server.prompts == []
|
|
|
|
def test_full_initialization(self):
|
|
server = McpServerInfo(
|
|
name="my-server",
|
|
transport="SSE",
|
|
command="npx",
|
|
args=["-y", "server-package"],
|
|
env={"MY_VAR": "test-value"},
|
|
url="http://localhost:8080",
|
|
api_key="test-api-key-00000", # NOSONAR
|
|
)
|
|
assert server.name == "my-server"
|
|
assert server.transport == "SSE"
|
|
assert server.command == "npx"
|
|
assert server.args == ["-y", "server-package"]
|
|
assert server.env == {"MY_VAR": "test-value"}
|
|
assert server.url == "http://localhost:8080"
|
|
assert server.api_key == "test-api-key-00000" # NOSONAR
|
|
|
|
|
|
class TestStdioTransport:
|
|
"""Tests for StdioTransport class"""
|
|
|
|
def test_initialization(self):
|
|
transport = StdioTransport(
|
|
command="python",
|
|
args=["-m", "mcp_server"],
|
|
env={"DEBUG": "true"},
|
|
timeout=60,
|
|
)
|
|
assert transport.command == "python"
|
|
assert transport.args == ["-m", "mcp_server"]
|
|
assert transport.env == {"DEBUG": "true"}
|
|
assert transport.timeout == 60
|
|
|
|
def test_get_next_id_increments(self):
|
|
transport = StdioTransport(command="echo")
|
|
id1 = transport._get_next_id()
|
|
id2 = transport._get_next_id()
|
|
id3 = transport._get_next_id()
|
|
assert id1 == 1
|
|
assert id2 == 2
|
|
assert id3 == 3
|
|
|
|
def test_connect_command_not_found(self):
|
|
transport = StdioTransport(command="nonexistent_command_12345")
|
|
with pytest.raises(McpProtocolError) as exc_info:
|
|
transport.connect()
|
|
assert "Command not found" in str(exc_info.value)
|
|
|
|
def test_send_request_not_connected(self):
|
|
transport = StdioTransport(command="echo")
|
|
with pytest.raises(McpProtocolError) as exc_info:
|
|
transport.send_request("test/method")
|
|
assert "not connected" in str(exc_info.value).lower()
|
|
|
|
|
|
class TestHttpTransport:
|
|
"""Tests for HttpTransport class"""
|
|
|
|
def test_initialization(self):
|
|
transport = HttpTransport(
|
|
url="http://localhost:8080/",
|
|
api_key="test-api-key-00000", # NOSONAR
|
|
timeout=30,
|
|
)
|
|
assert transport.url == "http://localhost:8080"
|
|
assert transport.api_key == "test-api-key-00000"
|
|
assert transport.timeout == 30
|
|
|
|
def test_url_trailing_slash_removed(self):
|
|
transport = HttpTransport(url="http://example.com/api/")
|
|
assert transport.url == "http://example.com/api"
|
|
|
|
def test_connect_sets_headers(self):
|
|
transport = HttpTransport(
|
|
url="http://localhost:8080",
|
|
api_key="test-api-key-00000", # NOSONAR
|
|
)
|
|
transport.connect()
|
|
assert "Authorization" in transport.session.headers
|
|
assert transport.session.headers["Authorization"] == "Bearer test-api-key-00000"
|
|
assert transport.session.headers["Content-Type"] == "application/json"
|
|
|
|
@patch("requests.Session.post")
|
|
def test_send_request_success(self, mock_post):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"jsonrpc": "2.0",
|
|
"id": "123",
|
|
"result": {"tools": []},
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_post.return_value = mock_response
|
|
|
|
transport = HttpTransport(url="http://localhost:8080")
|
|
transport.connect()
|
|
result = transport.send_request("tools/list")
|
|
|
|
assert result == {"tools": []}
|
|
mock_post.assert_called_once()
|
|
|
|
@patch("requests.Session.post")
|
|
def test_send_request_error_response(self, mock_post):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"jsonrpc": "2.0",
|
|
"id": "123",
|
|
"error": {"code": -32600, "message": "Invalid Request"},
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_post.return_value = mock_response
|
|
|
|
transport = HttpTransport(url="http://localhost:8080")
|
|
transport.connect()
|
|
|
|
with pytest.raises(McpProtocolError) as exc_info:
|
|
transport.send_request("invalid/method")
|
|
assert "Invalid Request" in str(exc_info.value)
|
|
|
|
@patch("requests.Session.post")
|
|
def test_send_notification_logs_on_failure(self, mock_post):
|
|
"""send_notification should log warnings, not silently swallow errors"""
|
|
mock_post.side_effect = ConnectionError("server down")
|
|
|
|
transport = HttpTransport(url="http://localhost:8080")
|
|
transport.connect()
|
|
|
|
with patch("metadata.ingestion.source.mcp.client.logger") as mock_logger:
|
|
transport.send_notification("notifications/initialized", {})
|
|
mock_logger.error.assert_called_once()
|
|
assert "server down" in str(mock_logger.error.call_args)
|
|
|
|
|
|
class TestMcpClient:
|
|
"""Tests for McpClient class"""
|
|
|
|
def test_initialization(self):
|
|
server = McpServerInfo(name="test", command="echo")
|
|
client = McpClient(
|
|
server_config=server,
|
|
connection_timeout=30,
|
|
initialization_timeout=60,
|
|
)
|
|
assert client.server_config == server
|
|
assert client.connection_timeout == 30
|
|
assert client.initialization_timeout == 60
|
|
|
|
def test_list_tools_not_initialized(self):
|
|
server = McpServerInfo(name="test", command="echo")
|
|
client = McpClient(server_config=server)
|
|
with pytest.raises(McpProtocolError) as exc_info:
|
|
client.list_tools()
|
|
assert "not initialized" in str(exc_info.value).lower()
|
|
|
|
def test_list_resources_not_initialized(self):
|
|
server = McpServerInfo(name="test", command="echo")
|
|
client = McpClient(server_config=server)
|
|
with pytest.raises(McpProtocolError) as exc_info:
|
|
client.list_resources()
|
|
assert "not initialized" in str(exc_info.value).lower()
|
|
|
|
def test_list_prompts_not_initialized(self):
|
|
server = McpServerInfo(name="test", command="echo")
|
|
client = McpClient(server_config=server)
|
|
with pytest.raises(McpProtocolError) as exc_info:
|
|
client.list_prompts()
|
|
assert "not initialized" in str(exc_info.value).lower()
|
|
|
|
|
|
class TestParseClaudeDesktopConfig:
|
|
"""Tests for parse_claude_desktop_config function"""
|
|
|
|
def test_parse_valid_config(self):
|
|
config = {
|
|
"mcpServers": {
|
|
"filesystem": {
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
"env": {"DEBUG": "true"},
|
|
},
|
|
"github": {
|
|
"command": "uvx",
|
|
"args": ["mcp-server-github"],
|
|
},
|
|
}
|
|
}
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump(config, f)
|
|
f.flush()
|
|
|
|
servers = parse_claude_desktop_config(f.name)
|
|
|
|
assert len(servers) == 2
|
|
|
|
fs_server = next(s for s in servers if s.name == "filesystem")
|
|
assert fs_server.command == "npx"
|
|
assert fs_server.args == [
|
|
"-y",
|
|
"@modelcontextprotocol/server-filesystem",
|
|
"/tmp",
|
|
]
|
|
assert fs_server.env == {"DEBUG": "true"}
|
|
assert fs_server.transport == "Stdio"
|
|
|
|
gh_server = next(s for s in servers if s.name == "github")
|
|
assert gh_server.command == "uvx"
|
|
assert gh_server.args == ["mcp-server-github"]
|
|
|
|
def test_parse_empty_config(self):
|
|
config = {"mcpServers": {}}
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump(config, f)
|
|
f.flush()
|
|
servers = parse_claude_desktop_config(f.name)
|
|
|
|
assert servers == []
|
|
|
|
def test_parse_nonexistent_file(self):
|
|
servers = parse_claude_desktop_config("/nonexistent/path/config.json")
|
|
assert servers == []
|
|
|
|
def test_parse_invalid_json(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
f.write("not valid json {")
|
|
f.flush()
|
|
servers = parse_claude_desktop_config(f.name)
|
|
|
|
assert servers == []
|
|
|
|
|
|
class TestParseVscodeConfig:
|
|
"""Tests for parse_vscode_config function"""
|
|
|
|
def test_parse_valid_config(self):
|
|
config = {
|
|
"mcp.servers": {
|
|
"postgres": {
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-postgres"],
|
|
"transport": "SSE",
|
|
"url": "http://localhost:3000",
|
|
}
|
|
}
|
|
}
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump(config, f)
|
|
f.flush()
|
|
servers = parse_vscode_config(f.name)
|
|
|
|
assert len(servers) == 1
|
|
assert servers[0].name == "postgres"
|
|
assert servers[0].transport == "SSE"
|
|
assert servers[0].url == "http://localhost:3000"
|
|
|
|
def test_parse_nonexistent_file(self):
|
|
servers = parse_vscode_config("/nonexistent/settings.json")
|
|
assert servers == []
|
|
|
|
|
|
class TestDiscoverServersFromConfigFiles:
|
|
"""Tests for discover_servers_from_config_files function"""
|
|
|
|
def test_discover_from_multiple_files(self):
|
|
config1 = {
|
|
"mcpServers": {
|
|
"server1": {"command": "cmd1"},
|
|
}
|
|
}
|
|
config2 = {
|
|
"mcpServers": {
|
|
"server2": {"command": "cmd2"},
|
|
}
|
|
}
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f1:
|
|
json.dump(config1, f1)
|
|
f1.flush()
|
|
path1 = f1.name
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f2:
|
|
json.dump(config2, f2)
|
|
f2.flush()
|
|
path2 = f2.name
|
|
|
|
servers = discover_servers_from_config_files([path1, path2])
|
|
|
|
assert len(servers) == 2
|
|
names = {s.name for s in servers}
|
|
assert "server1" in names
|
|
assert "server2" in names
|
|
|
|
def test_discover_deduplicates_by_name(self):
|
|
config1 = {"mcpServers": {"duplicate": {"command": "cmd1"}}}
|
|
config2 = {"mcpServers": {"duplicate": {"command": "cmd2"}}}
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f1:
|
|
json.dump(config1, f1)
|
|
f1.flush()
|
|
path1 = f1.name
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f2:
|
|
json.dump(config2, f2)
|
|
f2.flush()
|
|
path2 = f2.name
|
|
|
|
servers = discover_servers_from_config_files([path1, path2])
|
|
|
|
assert len(servers) == 1
|
|
assert servers[0].name == "duplicate"
|
|
assert servers[0].command == "cmd1"
|
|
|
|
def test_discover_with_nonexistent_files(self):
|
|
servers = discover_servers_from_config_files(["/nonexistent1.json", "/nonexistent2.json"])
|
|
assert servers == []
|
|
|
|
def test_discover_empty_list(self):
|
|
servers = discover_servers_from_config_files([])
|
|
assert servers == []
|