53
README.md
53
README.md
@@ -160,7 +160,58 @@ The final implementation resulted in a 26% improvement in JSON parsing performan
|
|||||||
> If all APIs are configured, native APIs will take priority when there is a clash in model name, such as for `gemini` and `o3`.
|
> If all APIs are configured, native APIs will take priority when there is a clash in model name, such as for `gemini` and `o3`.
|
||||||
> Configure your model aliases and give them unique names in [`conf/custom_models.json`](conf/custom_models.json)
|
> Configure your model aliases and give them unique names in [`conf/custom_models.json`](conf/custom_models.json)
|
||||||
|
|
||||||
### 2. Clone and Set Up
|
### 2. Choose Your Installation Method
|
||||||
|
|
||||||
|
**Option A: Quick Install with uvx**
|
||||||
|
|
||||||
|
**Prerequisites**: Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first (required for uvx)
|
||||||
|
|
||||||
|
For **Claude Desktop**, add this to your `claude_desktop_config.json`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"zen": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"--from",
|
||||||
|
"git+https://github.com/BeehiveInnovations/zen-mcp-server.git",
|
||||||
|
"zen-mcp-server"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"OPENAI_API_KEY": "your_api_key_here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Claude Code CLI**, create a `.mcp.json` file in your project root for [project-scoped configuration](https://docs.anthropic.com/en/docs/claude-code/mcp#project-scope):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"zen": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"--from",
|
||||||
|
"git+https://github.com/BeehiveInnovations/zen-mcp-server.git",
|
||||||
|
"zen-mcp-server"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"OPENAI_API_KEY": "your_api_key_here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this does:**
|
||||||
|
- **Zero setup required** - uvx handles everything automatically
|
||||||
|
- **Always up-to-date** - Pulls latest version on each run
|
||||||
|
- **No local dependencies** - Works without Python environment setup
|
||||||
|
- **Instant availability** - Ready to use immediately
|
||||||
|
|
||||||
|
|
||||||
|
**Option B: Traditional Clone and Set Up**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone to your preferred location
|
# Clone to your preferred location
|
||||||
|
|||||||
@@ -94,13 +94,14 @@ class CommunicationSimulator:
|
|||||||
self.quick_mode = quick_mode
|
self.quick_mode = quick_mode
|
||||||
self.temp_dir = None
|
self.temp_dir = None
|
||||||
self.server_process = None
|
self.server_process = None
|
||||||
self.python_path = self._get_python_path()
|
|
||||||
|
|
||||||
# Configure logging first
|
# Configure logging first
|
||||||
log_level = logging.DEBUG if verbose else logging.INFO
|
log_level = logging.DEBUG if verbose else logging.INFO
|
||||||
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
|
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
self.python_path = self._get_python_path()
|
||||||
|
|
||||||
# Import test registry
|
# Import test registry
|
||||||
from simulator_tests import TEST_REGISTRY
|
from simulator_tests import TEST_REGISTRY
|
||||||
|
|
||||||
@@ -133,8 +134,14 @@ class CommunicationSimulator:
|
|||||||
def _get_python_path(self) -> str:
|
def _get_python_path(self) -> str:
|
||||||
"""Get the Python path for the virtual environment"""
|
"""Get the Python path for the virtual environment"""
|
||||||
current_dir = os.getcwd()
|
current_dir = os.getcwd()
|
||||||
venv_python = os.path.join(current_dir, "venv", "bin", "python")
|
|
||||||
|
|
||||||
|
# Try .venv first (modern convention)
|
||||||
|
venv_python = os.path.join(current_dir, ".venv", "bin", "python")
|
||||||
|
if os.path.exists(venv_python):
|
||||||
|
return venv_python
|
||||||
|
|
||||||
|
# Try venv as fallback
|
||||||
|
venv_python = os.path.join(current_dir, "venv", "bin", "python")
|
||||||
if os.path.exists(venv_python):
|
if os.path.exists(venv_python):
|
||||||
return venv_python
|
return venv_python
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
|
[project]
|
||||||
|
name = "zen-mcp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI-powered MCP server with multiple model providers"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.0.0",
|
||||||
|
"google-genai>=1.19.0",
|
||||||
|
"openai>=1.55.2",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["tools*", "providers*", "systemprompts*", "utils*"]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["server", "config"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"*" = ["conf/*.json"]
|
||||||
|
|
||||||
|
[tool.setuptools.data-files]
|
||||||
|
"conf" = ["conf/custom_models.json"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
zen-mcp-server = "server:run"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
|
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
|
||||||
|
|||||||
44
server.py
44
server.py
@@ -28,13 +28,20 @@ from logging.handlers import RotatingFileHandler
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
# Try to load environment variables from .env file if dotenv is available
|
||||||
|
# This is optional - environment variables can still be passed directly
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load environment variables from .env file in the script's directory
|
# Load environment variables from .env file in the script's directory
|
||||||
# This ensures .env is loaded regardless of the current working directory
|
# This ensures .env is loaded regardless of the current working directory
|
||||||
script_dir = Path(__file__).parent
|
script_dir = Path(__file__).parent
|
||||||
env_file = script_dir / ".env"
|
env_file = script_dir / ".env"
|
||||||
load_dotenv(dotenv_path=env_file)
|
load_dotenv(dotenv_path=env_file)
|
||||||
|
except ImportError:
|
||||||
|
# dotenv not available - this is fine, environment variables can still be passed directly
|
||||||
|
# This commonly happens when running via uvx or in minimal environments
|
||||||
|
pass
|
||||||
|
|
||||||
from mcp.server import Server # noqa: E402
|
from mcp.server import Server # noqa: E402
|
||||||
from mcp.server.models import InitializationOptions # noqa: E402
|
from mcp.server.models import InitializationOptions # noqa: E402
|
||||||
@@ -362,6 +369,12 @@ def configure_providers():
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If no valid API keys are found or conflicting configurations detected
|
ValueError: If no valid API keys are found or conflicting configurations detected
|
||||||
"""
|
"""
|
||||||
|
# Log environment variable status for debugging
|
||||||
|
logger.debug("Checking environment variables for API keys...")
|
||||||
|
api_keys_to_check = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "CUSTOM_API_URL"]
|
||||||
|
for key in api_keys_to_check:
|
||||||
|
value = os.getenv(key)
|
||||||
|
logger.debug(f" {key}: {'[PRESENT]' if value else '[MISSING]'}")
|
||||||
from providers import ModelProviderRegistry
|
from providers import ModelProviderRegistry
|
||||||
from providers.base import ProviderType
|
from providers.base import ProviderType
|
||||||
from providers.custom import CustomProvider
|
from providers.custom import CustomProvider
|
||||||
@@ -386,10 +399,16 @@ def configure_providers():
|
|||||||
|
|
||||||
# Check for OpenAI API key
|
# Check for OpenAI API key
|
||||||
openai_key = os.getenv("OPENAI_API_KEY")
|
openai_key = os.getenv("OPENAI_API_KEY")
|
||||||
|
logger.debug(f"OpenAI key check: key={'[PRESENT]' if openai_key else '[MISSING]'}")
|
||||||
if openai_key and openai_key != "your_openai_api_key_here":
|
if openai_key and openai_key != "your_openai_api_key_here":
|
||||||
valid_providers.append("OpenAI (o3)")
|
valid_providers.append("OpenAI (o3)")
|
||||||
has_native_apis = True
|
has_native_apis = True
|
||||||
logger.info("OpenAI API key found - o3 model available")
|
logger.info("OpenAI API key found - o3 model available")
|
||||||
|
else:
|
||||||
|
if not openai_key:
|
||||||
|
logger.debug("OpenAI API key not found in environment")
|
||||||
|
else:
|
||||||
|
logger.debug("OpenAI API key is placeholder value")
|
||||||
|
|
||||||
# Check for X.AI API key
|
# Check for X.AI API key
|
||||||
xai_key = os.getenv("XAI_API_KEY")
|
xai_key = os.getenv("XAI_API_KEY")
|
||||||
@@ -407,10 +426,16 @@ def configure_providers():
|
|||||||
|
|
||||||
# Check for OpenRouter API key
|
# Check for OpenRouter API key
|
||||||
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
||||||
|
logger.debug(f"OpenRouter key check: key={'[PRESENT]' if openrouter_key else '[MISSING]'}")
|
||||||
if openrouter_key and openrouter_key != "your_openrouter_api_key_here":
|
if openrouter_key and openrouter_key != "your_openrouter_api_key_here":
|
||||||
valid_providers.append("OpenRouter")
|
valid_providers.append("OpenRouter")
|
||||||
has_openrouter = True
|
has_openrouter = True
|
||||||
logger.info("OpenRouter API key found - Multiple models available via OpenRouter")
|
logger.info("OpenRouter API key found - Multiple models available via OpenRouter")
|
||||||
|
else:
|
||||||
|
if not openrouter_key:
|
||||||
|
logger.debug("OpenRouter API key not found in environment")
|
||||||
|
else:
|
||||||
|
logger.debug("OpenRouter API key is placeholder value")
|
||||||
|
|
||||||
# Check for custom API endpoint (Ollama, vLLM, etc.)
|
# Check for custom API endpoint (Ollama, vLLM, etc.)
|
||||||
custom_url = os.getenv("CUSTOM_API_URL")
|
custom_url = os.getenv("CUSTOM_API_URL")
|
||||||
@@ -1285,9 +1310,14 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def run():
|
||||||
|
"""Console script entry point for zen-mcp-server."""
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
# Handle graceful shutdown
|
# Handle graceful shutdown
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
|
|||||||
@@ -21,21 +21,33 @@ class BaseSimulatorTest:
|
|||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.test_files = {}
|
self.test_files = {}
|
||||||
self.test_dir = None
|
self.test_dir = None
|
||||||
self.python_path = self._get_python_path()
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging first
|
||||||
log_level = logging.DEBUG if verbose else logging.INFO
|
log_level = logging.DEBUG if verbose else logging.INFO
|
||||||
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
|
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||||
self.logger = logging.getLogger(self.__class__.__name__)
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
self.python_path = self._get_python_path()
|
||||||
|
|
||||||
def _get_python_path(self) -> str:
|
def _get_python_path(self) -> str:
|
||||||
"""Get the Python path for the virtual environment"""
|
"""Get the Python path for the virtual environment"""
|
||||||
current_dir = os.getcwd()
|
current_dir = os.getcwd()
|
||||||
venv_python = os.path.join(current_dir, ".zen_venv", "bin", "python")
|
|
||||||
|
|
||||||
|
# Try .venv first (modern convention)
|
||||||
|
venv_python = os.path.join(current_dir, ".venv", "bin", "python")
|
||||||
if os.path.exists(venv_python):
|
if os.path.exists(venv_python):
|
||||||
return venv_python
|
return venv_python
|
||||||
|
|
||||||
|
# Try venv as fallback
|
||||||
|
venv_python = os.path.join(current_dir, "venv", "bin", "python")
|
||||||
|
if os.path.exists(venv_python):
|
||||||
|
return venv_python
|
||||||
|
|
||||||
|
# Try .zen_venv as fallback
|
||||||
|
zen_venv_python = os.path.join(current_dir, ".zen_venv", "bin", "python")
|
||||||
|
if os.path.exists(zen_venv_python):
|
||||||
|
return zen_venv_python
|
||||||
|
|
||||||
# Fallback to system python if venv doesn't exist
|
# Fallback to system python if venv doesn't exist
|
||||||
self.logger.warning("Virtual environment not found, using system python")
|
self.logger.warning("Virtual environment not found, using system python")
|
||||||
return "python"
|
return "python"
|
||||||
|
|||||||
166
tests/test_uvx_support.py
Normal file
166
tests/test_uvx_support.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Test cases for uvx support and environment handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestUvxEnvironmentHandling:
|
||||||
|
"""Test uvx-specific environment handling features."""
|
||||||
|
|
||||||
|
def test_dotenv_import_success(self):
|
||||||
|
"""Test that dotenv is imported successfully when available."""
|
||||||
|
# Mock successful dotenv import
|
||||||
|
with mock.patch.dict("sys.modules", {"dotenv": mock.MagicMock()}):
|
||||||
|
with mock.patch("dotenv.load_dotenv") as mock_load_dotenv:
|
||||||
|
# Re-import server module to trigger the import logic
|
||||||
|
if "server" in sys.modules:
|
||||||
|
del sys.modules["server"]
|
||||||
|
|
||||||
|
import server # noqa: F401
|
||||||
|
|
||||||
|
# Should have called load_dotenv with the correct path
|
||||||
|
mock_load_dotenv.assert_called_once()
|
||||||
|
call_args = mock_load_dotenv.call_args
|
||||||
|
assert "dotenv_path" in call_args.kwargs
|
||||||
|
|
||||||
|
def test_dotenv_import_failure_graceful_handling(self):
|
||||||
|
"""Test that ImportError for dotenv is handled gracefully (uvx scenario)."""
|
||||||
|
# Mock only the dotenv import to fail
|
||||||
|
original_import = __builtins__["__import__"]
|
||||||
|
|
||||||
|
def mock_import(name, *args, **kwargs):
|
||||||
|
if name == "dotenv":
|
||||||
|
raise ImportError("No module named 'dotenv'")
|
||||||
|
return original_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
with mock.patch("builtins.__import__", side_effect=mock_import):
|
||||||
|
# This should not raise an exception when trying to import dotenv
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv # noqa: F401
|
||||||
|
|
||||||
|
pytest.fail("Should have raised ImportError for dotenv")
|
||||||
|
except ImportError:
|
||||||
|
# Expected behavior - ImportError should be caught gracefully in server.py
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_env_file_path_resolution(self):
|
||||||
|
"""Test that .env file path is correctly resolved relative to server.py."""
|
||||||
|
import server
|
||||||
|
|
||||||
|
# Test that the server module correctly resolves .env path
|
||||||
|
script_dir = Path(server.__file__).parent
|
||||||
|
expected_env_file = script_dir / ".env"
|
||||||
|
|
||||||
|
# The logic should create a path relative to server.py
|
||||||
|
assert expected_env_file.name == ".env"
|
||||||
|
assert expected_env_file.parent == script_dir
|
||||||
|
|
||||||
|
def test_environment_variables_still_work_without_dotenv(self):
|
||||||
|
"""Test that environment variables work even when dotenv is not available."""
|
||||||
|
# Set a test environment variable
|
||||||
|
test_key = "TEST_ZEN_MCP_VAR"
|
||||||
|
test_value = "test_value_123"
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, {test_key: test_value}):
|
||||||
|
# Environment variable should still be accessible regardless of dotenv
|
||||||
|
assert os.getenv(test_key) == test_value
|
||||||
|
|
||||||
|
def test_dotenv_graceful_fallback_behavior(self):
|
||||||
|
"""Test the actual graceful fallback behavior in server module."""
|
||||||
|
# Test that server module handles missing dotenv gracefully
|
||||||
|
# This is tested by the fact that the server can be imported even if dotenv fails
|
||||||
|
import server
|
||||||
|
|
||||||
|
# If we can import server, the graceful handling works
|
||||||
|
assert hasattr(server, "run")
|
||||||
|
|
||||||
|
# Test that environment variables still work
|
||||||
|
test_key = "TEST_FALLBACK_VAR"
|
||||||
|
test_value = "fallback_test_123"
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, {test_key: test_value}):
|
||||||
|
assert os.getenv(test_key) == test_value
|
||||||
|
|
||||||
|
|
||||||
|
class TestUvxProjectConfiguration:
|
||||||
|
"""Test uvx-specific project configuration features."""
|
||||||
|
|
||||||
|
def test_pyproject_toml_has_required_uvx_fields(self):
|
||||||
|
"""Test that pyproject.toml has all required fields for uvx support."""
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
# tomllib is only available in Python 3.11+
|
||||||
|
# For older versions, use tomli or skip the test
|
||||||
|
try:
|
||||||
|
import tomli as tomllib
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("tomllib/tomli not available for TOML parsing")
|
||||||
|
|
||||||
|
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||||
|
assert pyproject_path.exists(), "pyproject.toml should exist"
|
||||||
|
|
||||||
|
with open(pyproject_path, "rb") as f:
|
||||||
|
pyproject_data = tomllib.load(f)
|
||||||
|
|
||||||
|
# Check required uvx fields
|
||||||
|
assert "project" in pyproject_data
|
||||||
|
project = pyproject_data["project"]
|
||||||
|
|
||||||
|
# Essential fields for uvx
|
||||||
|
assert "name" in project
|
||||||
|
assert project["name"] == "zen-mcp-server"
|
||||||
|
assert "dependencies" in project
|
||||||
|
assert "requires-python" in project
|
||||||
|
|
||||||
|
# Script entry point for uvx
|
||||||
|
assert "scripts" in project
|
||||||
|
assert "zen-mcp-server" in project["scripts"]
|
||||||
|
assert project["scripts"]["zen-mcp-server"] == "server:run"
|
||||||
|
|
||||||
|
def test_pyproject_dependencies_match_requirements(self):
|
||||||
|
"""Test that pyproject.toml dependencies align with requirements.txt."""
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
# tomllib is only available in Python 3.11+
|
||||||
|
try:
|
||||||
|
import tomli as tomllib
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("tomllib/tomli not available for TOML parsing")
|
||||||
|
|
||||||
|
# Read pyproject.toml
|
||||||
|
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||||
|
with open(pyproject_path, "rb") as f:
|
||||||
|
pyproject_data = tomllib.load(f)
|
||||||
|
|
||||||
|
pyproject_deps = set(pyproject_data["project"]["dependencies"])
|
||||||
|
|
||||||
|
# Read requirements.txt
|
||||||
|
requirements_path = Path(__file__).parent.parent / "requirements.txt"
|
||||||
|
if requirements_path.exists():
|
||||||
|
# Note: We primarily validate pyproject.toml has core dependencies
|
||||||
|
# requirements.txt might have additional dev dependencies
|
||||||
|
|
||||||
|
# Core dependencies should be present in both
|
||||||
|
core_packages = {"mcp", "openai", "google-genai", "pydantic", "python-dotenv"}
|
||||||
|
|
||||||
|
for pkg in core_packages:
|
||||||
|
pyproject_has = any(pkg in dep for dep in pyproject_deps)
|
||||||
|
|
||||||
|
assert pyproject_has, f"{pkg} should be in pyproject.toml dependencies"
|
||||||
|
# requirements.txt might have additional dev dependencies
|
||||||
|
|
||||||
|
def test_uvx_entry_point_callable(self):
|
||||||
|
"""Test that the uvx entry point (server:run) is callable."""
|
||||||
|
import server
|
||||||
|
|
||||||
|
# The entry point should reference a callable function
|
||||||
|
assert hasattr(server, "run"), "server module should have a 'run' function"
|
||||||
|
assert callable(server.run), "server.run should be callable"
|
||||||
Reference in New Issue
Block a user