Skip to content

Commit 65750d1

Browse files
committed
✨ Adapt MCP protocal to different program language #416
[Specification Details] 1. Add test cases.
1 parent 6a5cbe6 commit 65750d1

File tree

2 files changed

+309
-0
lines changed

2 files changed

+309
-0
lines changed

test/backend/app/test_remote_mcp_app.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,34 @@ def test_add_mcp_from_config_missing_command(self, mock_container_manager_class,
621621
data = response.json()
622622
assert "command" in str(data["detail"]).lower()
623623

624+
@patch('apps.remote_mcp_app.get_current_user_id')
625+
@patch('apps.remote_mcp_app.MCPContainerManager')
626+
def test_add_mcp_from_config_empty_command(self, mock_container_manager_class, mock_get_user_id):
627+
"""Test adding MCP server with empty command string (covers line 189-191)"""
628+
mock_get_user_id.return_value = ("user123", "tenant456")
629+
630+
mock_container_manager = MagicMock()
631+
mock_container_manager_class.return_value = mock_container_manager
632+
633+
response = client.post(
634+
"/mcp/add-from-config",
635+
json={
636+
"mcpServers": {
637+
"test-service": {
638+
"command": "",
639+
"args": ["-y", "test-mcp"],
640+
"port": 5020
641+
}
642+
}
643+
},
644+
headers={"Authorization": "Bearer test_token"}
645+
)
646+
647+
assert response.status_code == HTTPStatus.BAD_REQUEST
648+
data = response.json()
649+
assert "All MCP servers failed" in data["detail"]
650+
assert "command is required" in data["detail"]
651+
624652
@patch('apps.remote_mcp_app.get_current_user_id')
625653
@patch('apps.remote_mcp_app.MCPContainerManager')
626654
def test_add_mcp_from_config_missing_port(self, mock_container_manager_class, mock_get_user_id):
@@ -687,6 +715,48 @@ def test_add_mcp_from_config_name_exists(self, mock_add_server, mock_container_m
687715
assert "MCP name already exists" in data["detail"]
688716
mock_container_manager.stop_mcp_container.assert_called_once_with("container-123")
689717

718+
@patch('apps.remote_mcp_app.get_current_user_id')
719+
@patch('apps.remote_mcp_app.MCPContainerManager')
720+
@patch('apps.remote_mcp_app.add_remote_mcp_server_list')
721+
def test_add_mcp_from_config_name_exists_stop_fails(self, mock_add_server, mock_container_manager_class, mock_get_user_id):
722+
"""Test adding MCP server when name exists and stopping container fails (covers line 236)"""
723+
mock_get_user_id.return_value = ("user123", "tenant456")
724+
725+
mock_container_manager = MagicMock()
726+
mock_container_manager_class.return_value = mock_container_manager
727+
mock_container_manager.start_mcp_container = AsyncMock(return_value={
728+
"container_id": "container-123",
729+
"mcp_url": "http://localhost:5020/mcp",
730+
"host_port": "5020",
731+
"status": "started",
732+
"container_name": "test-service-user1234"
733+
})
734+
# stop_mcp_container raises exception, should be silently caught
735+
mock_container_manager.stop_mcp_container = AsyncMock(side_effect=Exception("Stop failed"))
736+
737+
mock_add_server.side_effect = MCPNameIllegal("MCP name already exists")
738+
739+
response = client.post(
740+
"/mcp/add-from-config",
741+
json={
742+
"mcpServers": {
743+
"test-service": {
744+
"command": "npx",
745+
"args": ["-y", "test-mcp"],
746+
"port": 5020
747+
}
748+
}
749+
},
750+
headers={"Authorization": "Bearer test_token"}
751+
)
752+
753+
assert response.status_code == HTTPStatus.BAD_REQUEST
754+
data = response.json()
755+
assert "All MCP servers failed" in data["detail"]
756+
assert "MCP name already exists" in data["detail"]
757+
# Verify stop was attempted even though it failed
758+
mock_container_manager.stop_mcp_container.assert_called_once_with("container-123")
759+
690760
@patch('apps.remote_mcp_app.get_current_user_id')
691761
@patch('apps.remote_mcp_app.MCPContainerManager')
692762
def test_add_mcp_from_config_container_error(self, mock_container_manager_class, mock_get_user_id):
@@ -718,6 +788,36 @@ def test_add_mcp_from_config_container_error(self, mock_container_manager_class,
718788
assert "All MCP servers failed" in data["detail"]
719789
assert "Container failed" in data["detail"]
720790

791+
@patch('apps.remote_mcp_app.get_current_user_id')
792+
@patch('apps.remote_mcp_app.MCPContainerManager')
793+
def test_add_mcp_from_config_unexpected_error_in_loop(self, mock_container_manager_class, mock_get_user_id):
794+
"""Test adding MCP server when unexpected exception occurs in loop (covers line 253-255)"""
795+
mock_get_user_id.return_value = ("user123", "tenant456")
796+
797+
mock_container_manager = MagicMock()
798+
mock_container_manager_class.return_value = mock_container_manager
799+
# Raise a non-MCPContainerError exception to trigger the general Exception handler
800+
mock_container_manager.start_mcp_container = AsyncMock(side_effect=ValueError("Unexpected error"))
801+
802+
response = client.post(
803+
"/mcp/add-from-config",
804+
json={
805+
"mcpServers": {
806+
"test-service": {
807+
"command": "npx",
808+
"args": ["-y", "test-mcp"],
809+
"port": 5020
810+
}
811+
}
812+
},
813+
headers={"Authorization": "Bearer test_token"}
814+
)
815+
816+
assert response.status_code == HTTPStatus.BAD_REQUEST
817+
data = response.json()
818+
assert "All MCP servers failed" in data["detail"]
819+
assert "Unexpected error" in data["detail"]
820+
721821
@patch('apps.remote_mcp_app.get_current_user_id')
722822
@patch('apps.remote_mcp_app.MCPContainerManager')
723823
def test_add_mcp_from_config_all_fail(self, mock_container_manager_class, mock_get_user_id):
@@ -815,6 +915,30 @@ def test_add_mcp_from_config_with_custom_image(self, mock_add_server, mock_conta
815915
call_kwargs = mock_container_manager.start_mcp_container.call_args[1]
816916
assert call_kwargs["image"] == "custom-image:latest"
817917

918+
@patch('apps.remote_mcp_app.get_current_user_id')
919+
def test_add_mcp_from_config_outer_exception(self, mock_get_user_id):
920+
"""Test adding MCP server when exception occurs outside loop (covers line 275-277)"""
921+
# Make get_current_user_id raise an exception to trigger outer exception handler
922+
mock_get_user_id.side_effect = RuntimeError("Failed to get user ID")
923+
924+
response = client.post(
925+
"/mcp/add-from-config",
926+
json={
927+
"mcpServers": {
928+
"test-service": {
929+
"command": "npx",
930+
"args": ["-y", "test-mcp"],
931+
"port": 5020
932+
}
933+
}
934+
},
935+
headers={"Authorization": "Bearer test_token"}
936+
)
937+
938+
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
939+
data = response.json()
940+
assert "Failed to add MCP servers" in data["detail"]
941+
818942

819943
# ---------------------------------------------------------------------------
820944
# Test stop_mcp_container

test/sdk/container/test_docker_client.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,100 @@ async def test_start_container_existing_multiple_ports(self, docker_container_cl
887887
assert result["status"] == "existing"
888888
assert result["host_port"] == "5020"
889889

890+
@pytest.mark.asyncio
891+
async def test_start_container_existing_no_port_mapping_with_host_port(self, docker_container_client, mock_container):
892+
"""Test starting container when existing container has no port mapping but host_port is provided (line 228-229)"""
893+
docker_container_client.client.containers.get.return_value = mock_container
894+
mock_container.status = "running"
895+
mock_container.attrs = {
896+
"NetworkSettings": {
897+
"Ports": {} # No port mappings
898+
}
899+
}
900+
901+
with patch.object(DockerContainerClient, "_is_running_in_docker", return_value=False), \
902+
patch.object(DockerContainerClient, "_get_service_host", return_value="localhost"):
903+
result = await docker_container_client.start_container(
904+
service_name="test-service",
905+
tenant_id="tenant123",
906+
user_id="user12345",
907+
full_command=["npx", "-y", "test-mcp"],
908+
host_port=5025, # Provide host_port parameter
909+
)
910+
911+
# Should use existing container with provided host_port
912+
assert result["status"] == "existing"
913+
assert result["host_port"] == "5025"
914+
915+
@pytest.mark.asyncio
916+
async def test_start_container_empty_full_command(self, docker_container_client):
917+
"""Test starting container with empty full_command raises error (line 272-273)"""
918+
docker_container_client.client.containers.get.side_effect = NotFound("Container not found")
919+
920+
with pytest.raises(ContainerError, match="full_command is required to start container"):
921+
await docker_container_client.start_container(
922+
service_name="test-service",
923+
tenant_id="tenant123",
924+
user_id="user12345",
925+
full_command=[], # Empty command
926+
)
927+
928+
@pytest.mark.asyncio
929+
async def test_start_container_with_custom_image(self, docker_container_client):
930+
"""Test starting container with custom image parameter (line 276-277)"""
931+
docker_container_client.client.containers.get.side_effect = NotFound("Container not found")
932+
933+
new_container = MagicMock()
934+
new_container.id = "new-container-id"
935+
new_container.status = "running"
936+
new_container.reload.return_value = None
937+
docker_container_client.client.containers.run.return_value = new_container
938+
939+
with patch.object(DockerContainerClient, "find_free_port", return_value=5020), \
940+
patch.object(DockerContainerClient, "_get_service_host", return_value="localhost"), \
941+
patch.object(DockerContainerClient, "_wait_for_service_ready", new_callable=AsyncMock), \
942+
patch("asyncio.sleep", new_callable=AsyncMock):
943+
await docker_container_client.start_container(
944+
service_name="test-service",
945+
tenant_id="tenant123",
946+
user_id="user12345",
947+
full_command=["python", "script.py"],
948+
image="python:3.11-alpine", # Custom image
949+
)
950+
951+
call_args = docker_container_client.client.containers.run.call_args
952+
assert call_args is not None
953+
assert call_args.kwargs["image"] == "python:3.11-alpine"
954+
955+
@pytest.mark.asyncio
956+
async def test_start_container_with_host_port_provided(self, docker_container_client):
957+
"""Test starting container when host_port is provided (line 252 - skip find_free_port)"""
958+
docker_container_client.client.containers.get.side_effect = NotFound("Container not found")
959+
960+
new_container = MagicMock()
961+
new_container.id = "new-container-id"
962+
new_container.status = "running"
963+
new_container.reload.return_value = None
964+
docker_container_client.client.containers.run.return_value = new_container
965+
966+
with patch.object(DockerContainerClient, "find_free_port") as mock_find_port, \
967+
patch.object(DockerContainerClient, "_get_service_host", return_value="localhost"), \
968+
patch.object(DockerContainerClient, "_wait_for_service_ready", new_callable=AsyncMock), \
969+
patch("asyncio.sleep", new_callable=AsyncMock), \
970+
patch.object(DockerContainerClient, "_is_running_in_docker", return_value=False):
971+
await docker_container_client.start_container(
972+
service_name="test-service",
973+
tenant_id="tenant123",
974+
user_id="user12345",
975+
full_command=["npx", "-y", "test-mcp"],
976+
host_port=8080, # Provide host_port, should skip find_free_port
977+
)
978+
979+
# find_free_port should not be called when host_port is provided
980+
mock_find_port.assert_not_called()
981+
call_args = docker_container_client.client.containers.run.call_args
982+
assert call_args.kwargs["environment"]["PORT"] == "8080"
983+
890984

891985
# ---------------------------------------------------------------------------
892986
# Test _wait_for_service_ready
@@ -950,6 +1044,27 @@ async def test_wait_for_service_ready_exception(self, docker_container_client):
9501044
with pytest.raises(ContainerConnectionError):
9511045
await docker_container_client._wait_for_service_ready("http://localhost:5020/mcp", max_retries=3, retry_delay=0.1)
9521046

1047+
@pytest.mark.asyncio
1048+
async def test_wait_for_service_ready_loop_iterations(self, docker_container_client):
1049+
"""Test waiting for service ready with multiple loop iterations (line 356)"""
1050+
mock_client = MagicMock()
1051+
# Simulate multiple failures before success to test loop
1052+
call_count = 0
1053+
def is_connected():
1054+
nonlocal call_count
1055+
call_count += 1
1056+
return call_count >= 5 # Success on 5th attempt
1057+
mock_client.is_connected.side_effect = is_connected
1058+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
1059+
mock_client.__aexit__ = AsyncMock(return_value=False)
1060+
1061+
with patch("nexent.container.docker_client.Client", return_value=mock_client), \
1062+
patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
1063+
await docker_container_client._wait_for_service_ready("http://localhost:5020/mcp", max_retries=10, retry_delay=0.01)
1064+
1065+
# Should have slept 4 times (before 5th attempt succeeds)
1066+
assert mock_sleep.call_count == 4
1067+
9531068

9541069
# ---------------------------------------------------------------------------
9551070
# Test stop_container
@@ -1111,6 +1226,32 @@ def test_list_containers_empty_port_mapping(self, docker_container_client):
11111226
assert len(result) == 1
11121227
assert result[0]["host_port"] is None
11131228

1229+
def test_list_containers_host_port_none_or_empty(self, docker_container_client):
1230+
"""Test listing containers when host_port is None or empty string (line 448)"""
1231+
container = MagicMock()
1232+
container.id = "test-container-id"
1233+
container.name = "mcp-test-service-user12345"
1234+
container.status = "running"
1235+
container.attrs = {
1236+
"NetworkSettings": {
1237+
"Ports": {
1238+
"5020/tcp": [{"HostPort": None}], # None value
1239+
"5021/tcp": [{"HostPort": ""}], # Empty string
1240+
}
1241+
}
1242+
}
1243+
docker_container_client.client.containers.list.return_value = [container]
1244+
1245+
with patch.object(DockerContainerClient, "_get_service_host", return_value="localhost"), \
1246+
patch.object(DockerContainerClient, "_is_running_in_docker", return_value=False):
1247+
result = docker_container_client.list_containers()
1248+
1249+
assert len(result) == 1
1250+
# Should not break on None or empty HostPort
1251+
# When HostPort is empty string, it will be returned as empty string (not None)
1252+
# Since the last HostPort value is empty string, host_port will be empty string
1253+
assert result[0]["host_port"] == ""
1254+
11141255
def test_list_containers_exception(self, docker_container_client):
11151256
"""Test listing containers when exception occurs"""
11161257
docker_container_client.client.containers.list.side_effect = Exception("Connection error")
@@ -1269,6 +1410,34 @@ def test_get_container_status_empty_port_mapping(self, docker_container_client):
12691410
assert result is not None
12701411
assert result["host_port"] is None
12711412

1413+
def test_get_container_status_host_port_none_or_empty(self, docker_container_client):
1414+
"""Test getting container status when host_port is None or empty string (line 513)"""
1415+
container = MagicMock()
1416+
container.id = "test-container-id"
1417+
container.name = "mcp-test-service-user12345"
1418+
container.status = "running"
1419+
container.attrs = {
1420+
"NetworkSettings": {
1421+
"Ports": {
1422+
"5020/tcp": [{"HostPort": None}], # None value
1423+
"5021/tcp": [{"HostPort": ""}], # Empty string
1424+
}
1425+
},
1426+
"Created": "2024-01-01T00:00:00Z",
1427+
"Config": {"Image": "node:22-alpine"},
1428+
}
1429+
docker_container_client.client.containers.get.return_value = container
1430+
1431+
with patch.object(DockerContainerClient, "_get_service_host", return_value="localhost"), \
1432+
patch.object(DockerContainerClient, "_is_running_in_docker", return_value=False):
1433+
result = docker_container_client.get_container_status("test-container-id")
1434+
1435+
assert result is not None
1436+
# Should not break on None or empty HostPort
1437+
# When HostPort is empty string, it will be returned as empty string (not None)
1438+
# Since the last HostPort value is empty string, host_port will be empty string
1439+
assert result["host_port"] == ""
1440+
12721441

12731442
# ---------------------------------------------------------------------------
12741443
# Test _ensure_network
@@ -1503,6 +1672,22 @@ def test_get_container_service_port_no_hostport(self):
15031672
port = DockerContainerClient._get_container_service_port(container)
15041673
assert port is None
15051674

1675+
def test_get_container_service_port_non_string_env_item(self):
1676+
"""Test getting port when env list contains non-string items (line 131)"""
1677+
container = MagicMock()
1678+
container.attrs = {
1679+
"Config": {
1680+
"Env": [123, "PORT=5020", None, {"key": "value"}] # Mixed types
1681+
},
1682+
"NetworkSettings": {
1683+
"Ports": {}
1684+
}
1685+
}
1686+
1687+
port = DockerContainerClient._get_container_service_port(container)
1688+
# Should still find PORT=5020 despite non-string items
1689+
assert port == "5020"
1690+
15061691

15071692
# ---------------------------------------------------------------------------
15081693
# Test start_container in Docker mode

0 commit comments

Comments
 (0)