| """ |
| Tests for ProxyServer |
| |
| Tests the MCP proxy server functionality including HTTP/SSE transport, |
| resource URI rewriting, and expected_info parameter injection. |
| """ |
| import pytest |
| from unittest.mock import AsyncMock, Mock, patch, MagicMock |
| from pathlib import Path |
| from mcp.types import ( |
| TextContent, |
| CallToolResult, |
| Tool, |
| Resource, |
| ListResourcesResult, |
| ReadResourceResult, |
| TextResourceContents, |
| ) |
|
|
| from mcpuniverse.extensions.mcpplus.tools.proxy_server import ( |
| ProxyServer, |
| ProxyConfig, |
| _load_config, |
| ) |
|
|
|
|
| class TestProxyConfig: |
| """Test suite for ProxyConfig.""" |
|
|
| def test_default_config(self): |
| """Test default configuration values.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| ) |
| assert config.upstream_server == "test-server" |
| assert config.server_name == "proxy" |
| assert config.transport == "stdio" |
| assert config.upstream_address == "" |
| assert config.timeout == 30 |
| assert config.wrapper is None |
| assert config.llm is None |
| assert config.headers is None |
| assert config.upstream_command is None |
| assert config.upstream_args is None |
| assert config.upstream_env is None |
|
|
| def test_http_config(self): |
| """Test HTTP transport configuration.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| server_name="test-proxy", |
| transport="http", |
| upstream_address="http://localhost:8080/mcp", |
| headers={"Authorization": "Bearer token123"}, |
| ) |
| assert config.transport == "http" |
| assert config.upstream_address == "http://localhost:8080/mcp" |
| assert config.headers == {"Authorization": "Bearer token123"} |
|
|
| def test_sse_config(self): |
| """Test SSE transport configuration.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="sse", |
| upstream_address="http://localhost:8080/sse", |
| ) |
| assert config.transport == "sse" |
| assert config.upstream_address == "http://localhost:8080/sse" |
|
|
| def test_stdio_config(self): |
| """Test stdio transport configuration.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="stdio", |
| upstream_command="python", |
| upstream_args=["-m", "mcp.server"], |
| upstream_env={"ENV_VAR": "value"}, |
| ) |
| assert config.transport == "stdio" |
| assert config.upstream_command == "python" |
| assert config.upstream_args == ["-m", "mcp.server"] |
| assert config.upstream_env == {"ENV_VAR": "value"} |
|
|
| def test_load_config_from_file(self, tmp_path): |
| """Test loading config from JSON file.""" |
| config_file = tmp_path / "proxy_config.json" |
| config_data = { |
| "upstream_server": "test-server", |
| "server_name": "test-proxy", |
| "transport": "http", |
| "upstream_address": "http://localhost:8080/mcp", |
| "timeout": 60, |
| "headers": {"Authorization": "Bearer token"}, |
| "wrapper": { |
| "enabled": True, |
| "token_threshold": 1500, |
| }, |
| "llm": { |
| "name": "openai", |
| "config": {"model_name": "gpt-4o-mini", "api_key": "$OPENAI_API_KEY"}, |
| }, |
| } |
|
|
| import json |
| config_file.write_text(json.dumps(config_data)) |
|
|
| config = _load_config(str(config_file)) |
| assert config.upstream_server == "test-server" |
| assert config.server_name == "test-proxy" |
| assert config.transport == "http" |
| assert config.upstream_address == "http://localhost:8080/mcp" |
| assert config.timeout == 60 |
| assert config.headers == {"Authorization": "Bearer token"} |
| assert config.wrapper == {"enabled": True, "token_threshold": 1500} |
| assert config.llm["name"] == "openai" |
|
|
|
|
| class TestProxyServerInitialization: |
| """Test suite for ProxyServer initialization.""" |
|
|
| def test_proxy_server_init(self): |
| """Test ProxyServer initialization.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| assert server._config == config |
| assert server._mcp_manager is None |
| assert server._client is None |
| assert server._server is not None |
|
|
| def test_proxy_server_instructions(self): |
| """Test that proxy server has correct instructions.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| instructions = server._server._mcp_server.instructions |
| assert "MCP+ enabled version" in instructions |
| assert "test-server" in instructions |
| assert "post-processing" in instructions |
|
|
|
|
| class TestProxyServerHTTPTransport: |
| """Test suite for HTTP transport support.""" |
|
|
| @pytest.mark.asyncio |
| async def test_http_server_registration(self): |
| """Test HTTP server registration.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="http", |
| upstream_address="http://localhost:8080/mcp", |
| headers={"Authorization": "Bearer token"}, |
| ) |
| server = ProxyServer(config) |
|
|
| |
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client = Mock() |
| mock_manager.add_server_config = Mock() |
| mock_manager.build_client = AsyncMock(return_value=mock_client) |
| MockManager.return_value = mock_manager |
|
|
| await server._init_client() |
|
|
| |
| mock_manager.add_server_config.assert_called_once() |
| call_args = mock_manager.add_server_config.call_args |
| assert call_args[0][0] == "test-server" |
| server_config = call_args[0][1] |
| assert server_config["http_url"] == "http://localhost:8080/mcp" |
| assert server_config["headers"] == {"Authorization": "Bearer token"} |
|
|
| |
| mock_manager.build_client.assert_called_once() |
| build_args = mock_manager.build_client.call_args[1] |
| assert build_args["transport"] == "http" |
| assert build_args["mcp_gateway_address"] == "http://localhost:8080/mcp" |
|
|
| @pytest.mark.asyncio |
| async def test_sse_server_registration(self): |
| """Test SSE server registration.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="sse", |
| upstream_address="http://localhost:8080/sse", |
| ) |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client = Mock() |
| mock_manager.add_server_config = Mock() |
| mock_manager.build_client = AsyncMock(return_value=mock_client) |
| MockManager.return_value = mock_manager |
|
|
| await server._init_client() |
|
|
| |
| mock_manager.add_server_config.assert_called_once() |
| call_args = mock_manager.add_server_config.call_args |
| assert call_args[0][0] == "test-server" |
| server_config = call_args[0][1] |
| assert "sse" in server_config |
| assert server_config["sse"]["command"] == "http://localhost:8080/sse" |
|
|
| @pytest.mark.asyncio |
| async def test_http_to_sse_fallback(self): |
| """Test HTTP-to-SSE fallback on connection failure.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="http", |
| upstream_address="http://localhost:8080/mcp", |
| ) |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client_sse = Mock() |
|
|
| |
| mock_manager.build_client = AsyncMock( |
| side_effect=[ |
| ConnectionError("HTTP failed"), |
| mock_client_sse, |
| ] |
| ) |
| mock_manager.add_server_config = Mock() |
| mock_manager.update_server_config = Mock() |
| MockManager.return_value = mock_manager |
|
|
| await server._init_client() |
|
|
| |
| assert server._client == mock_client_sse |
|
|
| |
| assert mock_manager.update_server_config.called or mock_manager.add_server_config.call_count == 2 |
|
|
|
|
| class TestProxyServerResources: |
| """Test suite for resource URI rewriting.""" |
|
|
| @pytest.mark.asyncio |
| async def test_resource_uri_rewriting_on_list(self): |
| """Test that invalid URIs get mcpplus-proxy:// prefix when listing.""" |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| assert True |
|
|
| @pytest.mark.asyncio |
| async def test_resource_uri_stripping_on_read(self): |
| """Test that mcpplus-proxy:// prefix is stripped when reading resources.""" |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| assert True |
|
|
| @pytest.mark.asyncio |
| async def test_nested_uri_validation(self): |
| """Test that nested URI schemes are rejected.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| |
| |
|
|
| |
| |
| suspicious_uri = "mcpplus-proxy://http://malicious.com" |
|
|
| |
| |
|
|
| |
| |
|
|
|
|
| class TestProxyServerExpectedInfo: |
| """Test suite for expected_info parameter injection.""" |
|
|
| @pytest.mark.asyncio |
| async def test_expected_info_added_to_tools(self): |
| """Test that expected_info parameter is added to all tools.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| |
| mock_client = Mock() |
| mock_tool = Tool( |
| name="test_tool", |
| description="Test tool", |
| inputSchema={ |
| "type": "object", |
| "properties": { |
| "query": {"type": "string", "description": "Search query"} |
| }, |
| "required": ["query"], |
| }, |
| ) |
|
|
| mock_client.list_tools = AsyncMock(return_value=[mock_tool]) |
| mock_client.list_prompts = AsyncMock(return_value=[]) |
| server._client = mock_client |
|
|
| await server.start() |
|
|
| |
| |
| |
| |
| assert True |
|
|
| @pytest.mark.asyncio |
| async def test_expected_info_description(self): |
| """Test that expected_info has proper description.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| mock_client = Mock() |
| mock_tool = Tool( |
| name="test_tool", |
| description="Test tool", |
| inputSchema={ |
| "type": "object", |
| "properties": {}, |
| }, |
| ) |
|
|
| mock_client.list_tools = AsyncMock(return_value=[mock_tool]) |
| mock_client.list_prompts = AsyncMock(return_value=[]) |
| server._client = mock_client |
|
|
| await server.start() |
|
|
| |
| |
|
|
|
|
| class TestProxyServerStdioTransport: |
| """Test suite for stdio transport.""" |
|
|
| @pytest.mark.asyncio |
| async def test_stdio_server_registration(self): |
| """Test stdio server registration with command and args.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="stdio", |
| upstream_command="python", |
| upstream_args=["-m", "mcp.server"], |
| upstream_env={"ENV_VAR": "value"}, |
| ) |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client = Mock() |
| mock_manager.add_server_config = Mock() |
| mock_manager.build_client = AsyncMock(return_value=mock_client) |
| MockManager.return_value = mock_manager |
|
|
| await server._init_client() |
|
|
| |
| mock_manager.add_server_config.assert_called_once() |
| call_args = mock_manager.add_server_config.call_args |
| assert call_args[0][0] == "test-server" |
| server_config = call_args[0][1] |
| assert "stdio" in server_config |
| assert server_config["stdio"]["command"] == "python" |
| assert server_config["stdio"]["args"] == ["-m", "mcp.server"] |
| assert server_config["env"] == {"ENV_VAR": "value"} |
|
|
| @pytest.mark.asyncio |
| async def test_stdio_command_with_spaces(self): |
| """Test stdio command that contains spaces is properly split.""" |
| config = ProxyConfig( |
| upstream_server="test-server", |
| transport="stdio", |
| upstream_command="npx -y @playwright/mcp", |
| upstream_args=[], |
| ) |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client = Mock() |
| mock_manager.add_server_config = Mock() |
| mock_manager.build_client = AsyncMock(return_value=mock_client) |
| MockManager.return_value = mock_manager |
|
|
| await server._init_client() |
|
|
| |
| mock_manager.add_server_config.assert_called_once() |
| call_args = mock_manager.add_server_config.call_args |
| server_config = call_args[0][1] |
| assert server_config["stdio"]["command"] == "npx" |
| assert server_config["stdio"]["args"] == ["-y", "@playwright/mcp"] |
|
|
|
|
| class TestProxyServerLLMConfig: |
| """Test suite for LLM configuration.""" |
|
|
| @pytest.mark.asyncio |
| async def test_llm_config_with_env_var(self): |
| """Test LLM config with environment variable expansion.""" |
| import os |
| os.environ["TEST_API_KEY"] = "test-key-123" |
|
|
| config = ProxyConfig( |
| upstream_server="test-server", |
| llm={ |
| "name": "openai", |
| "config": { |
| "model_name": "gpt-4o-mini", |
| "api_key": "$TEST_API_KEY", |
| }, |
| }, |
| ) |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client = Mock() |
| mock_manager.build_client = AsyncMock(return_value=mock_client) |
| MockManager.return_value = mock_manager |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.ModelManager") as MockModelManager: |
| mock_llm_manager = Mock() |
| mock_llm = Mock() |
| mock_llm.set_context = Mock() |
| mock_llm_manager.build_model = Mock(return_value=mock_llm) |
| MockModelManager.return_value = mock_llm_manager |
|
|
| await server._init_client() |
|
|
| |
| build_call = mock_llm_manager.build_model.call_args |
| llm_config = build_call[1] |
| assert llm_config["config"]["api_key"] == "test-key-123" |
|
|
| del os.environ["TEST_API_KEY"] |
|
|
| @pytest.mark.asyncio |
| async def test_no_llm_config_warning(self): |
| """Test that warning is logged when no LLM config is provided.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_client = Mock() |
| mock_manager.build_client = AsyncMock(return_value=mock_client) |
| MockManager.return_value = mock_manager |
|
|
| |
| with patch.object(server._logger, 'warning') as mock_warning: |
| await server._init_client() |
|
|
| |
| assert mock_warning.called |
| warning_msg = str(mock_warning.call_args[0][0]) |
| assert "No LLM config provided" in warning_msg |
|
|
|
|
| class TestProxyServerErrorHandling: |
| """Test suite for error handling.""" |
|
|
| @pytest.mark.asyncio |
| async def test_client_init_failure_graceful(self): |
| """Test that proxy starts even if upstream connection fails.""" |
| config = ProxyConfig( |
| upstream_server="nonexistent-server", |
| transport="http", |
| upstream_address="http://localhost:9999/mcp", |
| ) |
| server = ProxyServer(config) |
|
|
| with patch("mcpuniverse.extensions.mcpplus.tools.proxy_server.MCPWrapperManager") as MockManager: |
| mock_manager = Mock() |
| mock_manager.build_client = AsyncMock(side_effect=Exception("Connection failed")) |
| MockManager.return_value = mock_manager |
|
|
| |
| with patch.object(server._logger, 'error') as mock_error: |
| await server.start() |
| assert mock_error.called |
|
|
| @pytest.mark.asyncio |
| async def test_tool_listing_failure_graceful(self): |
| """Test that proxy starts even if tool listing fails.""" |
| config = ProxyConfig(upstream_server="test-server") |
| server = ProxyServer(config) |
|
|
| mock_client = Mock() |
| mock_client.list_tools = AsyncMock(side_effect=Exception("List tools failed")) |
| server._client = mock_client |
|
|
| with patch.object(server, '_init_client', return_value=None): |
| with patch.object(server._logger, 'error') as mock_error: |
| await server.start() |
| assert mock_error.called |
|
|