├── tests ├── __init__.py ├── test_connection_manager.py ├── test_api_key_management.py ├── test_integration.py ├── test_health_checks.py └── test_server.py ├── run-server.sh ├── vectara_mcp ├── _version.py ├── __main__.py ├── __init__.py ├── auth.py ├── connection_manager.py ├── health_checks.py └── server.py ├── pytest.ini ├── requirements.txt ├── Dockerfile ├── .env.example ├── pyproject.toml ├── .github └── workflows │ └── publish-to-pypi.yml ├── Makefile ├── setup.py ├── CHANGELOG.md ├── CONTRIBUTING.md ├── .gitignore ├── CLAUDE.md ├── SECURITY.md ├── README.md ├── LICENSE └── uv.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /run-server.sh: -------------------------------------------------------------------------------- 1 | docker build -t vectara-mcp . 2 | docker run -d -p 8000:8000 vectara-mcp -------------------------------------------------------------------------------- /vectara_mcp/_version.py: -------------------------------------------------------------------------------- 1 | """Version information for Vectara MCP Server.""" 2 | 3 | __version__ = "0.2.1" 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = -v --tb=short 7 | asyncio_mode = auto -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastmcp>=0.4.1 2 | fastapi>=0.100.0 3 | uvicorn>=0.30.0 4 | aiohttp>=3.9.0 5 | pytest>=8.0.0 6 | pytest-asyncio>=0.23.0 7 | python-dotenv>=1.0.0 8 | tenacity>=8.5.0 -------------------------------------------------------------------------------- /vectara_mcp/__main__.py: -------------------------------------------------------------------------------- 1 | # __main__.py 2 | """ 3 | Vectara MCP Server entry point. 4 | 5 | Supports multiple transports: 6 | - HTTP (default, secure) 7 | - SSE (Server-Sent Events) 8 | - STDIO (for local development) 9 | """ 10 | 11 | from vectara_mcp.server import main 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | # Copy requirements file 6 | COPY requirements.txt . 7 | 8 | # Install dependencies 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | # Copy the application code 12 | COPY . . 13 | 14 | # Expose the port the app runs on 15 | EXPOSE 8000 16 | 17 | # Start the application 18 | CMD ["python", "server.py"] -------------------------------------------------------------------------------- /vectara_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Vectara MCP Server 3 | 4 | A Model Context Protocol server for Vectara Trusted Generative AI. 5 | """ 6 | 7 | from ._version import __version__ 8 | 9 | # Import main function for compatibility 10 | from .server import main, mcp 11 | 12 | # Define what gets imported with "from vectara-mcp import *" 13 | __all__ = ["mcp", "main", "__version__"] 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Vectara API Configuration 2 | # Copy this file to .env and fill in your actual values 3 | 4 | # Your Vectara API key (required for all tools) 5 | VECTARA_API_KEY=your_vectara_api_key_here 6 | 7 | # Comma-separated list of corpus keys for testing 8 | # Example: VECTARA_CORPUS_KEYS=corpus1,corpus2,corpus3 9 | VECTARA_CORPUS_KEYS=your_corpus_key1,your_corpus_key2 10 | 11 | # Optional: Test text for hallucination correction and factual consistency 12 | TEST_TEXT=Your sample generated text here for testing hallucination correction and factual consistency evaluation. 13 | 14 | # Optional: Test source documents (pipe-separated for multiple docs) 15 | TEST_SOURCE_DOCS=First source document text here.|Second source document text here. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "vectara-mcp" 3 | version = "0.2.1" 4 | description = "A Model Context Protocol (MCP) server that provides tools from Vectara" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "mcp>=1.6.0", 9 | "fastmcp>=0.4.1", 10 | "fastapi>=0.95.0", 11 | "uvicorn>=0.34.0", 12 | "aiohttp>=3.8.0", 13 | "tenacity>=8.0.0", 14 | "python-dotenv>=1.0.0", 15 | ] 16 | 17 | [project.optional-dependencies] 18 | test = [ 19 | "pytest>=7.0.0", 20 | "pytest-asyncio>=0.21.0", 21 | "pytest-cov>=4.0.0", 22 | "python-dotenv>=1.0.0", 23 | "pylint>=3.0.0", 24 | ] 25 | 26 | [build-system] 27 | requires = ["setuptools>=68.0.0"] 28 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.11' 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -e .[test] 24 | 25 | - name: Run tests 26 | run: | 27 | python -m pytest tests/ -v 28 | 29 | publish: 30 | needs: test 31 | runs-on: ubuntu-latest 32 | environment: release 33 | permissions: 34 | id-token: write 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up Python 40 | uses: actions/setup-python@v4 41 | with: 42 | python-version: '3.11' 43 | 44 | - name: Install build dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install build 48 | 49 | - name: Build package 50 | run: python -m build 51 | 52 | - name: Publish to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test lint install install-dev clean 2 | 3 | # Run all tests 4 | test: 5 | python -m pytest tests/ -v 6 | 7 | # Run tests with coverage 8 | test-cov: 9 | python -m pytest tests/ -v --cov=vectara_mcp --cov-report=term-missing 10 | 11 | # Run integration tests only 12 | test-integration: 13 | python -m pytest tests/test_integration.py -v -s 14 | 15 | # Run unit tests only (excludes integration tests) 16 | test-unit: 17 | python -m pytest tests/test_server.py tests/test_api_key_management.py tests/test_health_checks.py tests/test_connection_manager.py -v 18 | 19 | # Run linting 20 | lint: 21 | python -m pylint vectara_mcp/ --disable=C0114,C0115,C0116 22 | 23 | # Run linting with all checks (stricter) 24 | lint-strict: 25 | python -m pylint vectara_mcp/ 26 | 27 | # Install package in development mode 28 | install: 29 | pip install -e . 30 | 31 | # Install with test dependencies 32 | install-dev: 33 | pip install -e ".[test]" 34 | pip install pylint 35 | 36 | # Clean up build artifacts 37 | clean: 38 | rm -rf build/ 39 | rm -rf dist/ 40 | rm -rf *.egg-info/ 41 | rm -rf .pytest_cache/ 42 | rm -rf __pycache__/ 43 | find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true 44 | find . -type f -name "*.pyc" -delete 2>/dev/null || true 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="vectara-mcp", 5 | version="0.2.1", 6 | description="Open source MCP server for Vectara", 7 | long_description=open("README.md").read(), 8 | long_description_content_type="text/markdown", 9 | author="Ofer Mendelevitch", 10 | author_email="ofer@vectara.com", 11 | url="https://github.com/vectara/vectara-mcp", 12 | packages=find_packages(), # Automatically find all packages 13 | install_requires=[ 14 | "mcp>=1.6.0", 15 | "fastmcp>=0.4.1", 16 | "fastapi>=0.95.0", 17 | "uvicorn>=0.34.0", 18 | "aiohttp>=3.8.0", 19 | "tenacity>=8.0.0", 20 | "python-dotenv>=1.0.0", 21 | ], 22 | classifiers=[ 23 | "Development Status :: 4 - Beta", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: Apache Software License", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python :: 3.11", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | "Topic :: Text Processing :: Linguistic", 30 | ], 31 | python_requires=">=3.11", # Specify the minimum Python version 32 | entry_points={ 33 | "console_scripts": [ 34 | "vectara-mcp=vectara_mcp.server:main", 35 | ], 36 | }, 37 | keywords="vectara, mcp, rag, ai, search, semantic-search", 38 | project_urls={ 39 | "Bug Reports": "https://github.com/vectara/vectara-mcp/issues", 40 | "Source": "https://github.com/vectara/vectara-mcp", 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.0] - 2025-01-07 9 | 10 | ### Added 11 | - Added tenacity library for robust retry handling with exponential backoff 12 | - Added centralized constants for timeouts and configuration values 13 | - Added version consistency across all package files 14 | 15 | ### Changed 16 | - Updated all hardcoded version strings to use `__version__` from `__init__.py` 17 | - Centralized magic numbers (timeouts, rate limits) into named constants 18 | - Improved DRY compliance by eliminating duplicate API key error messages 19 | - Made `correct_hallucinations` and `eval_factual_consistency` tools fully self-contained 20 | 21 | ### Improved 22 | - **Code Quality**: Reduced code duplication and improved maintainability 23 | - **Reliability**: Replaced custom retry logic with proven library used by major projects 24 | - **Consistency**: Unified error handling and validation patterns across all MCP tools 25 | - **Configuration**: Centralized timeout and limit configurations for easier tuning 26 | 27 | ### Fixed 28 | - Fixed version inconsistencies between `__init__.py`, `pyproject.toml`, `setup.py`, and hardcoded strings 29 | - Synchronized missing dependencies between `requirements.txt`, `pyproject.toml`, and `setup.py` 30 | - Resolved "Event loop is closed" issues in async retry contexts 31 | 32 | ### Technical Details 33 | - Retry logic now uses `AsyncRetrying` with configurable stop conditions and wait strategies 34 | - All timeout values now reference named constants (e.g., `DEFAULT_TOTAL_TIMEOUT = 30`) 35 | - Rate limiting parameters now use constants (`DEFAULT_MAX_REQUESTS = 100`) 36 | - Version information now sourced from single source of truth (`__version__`) 37 | 38 | ## [0.1.5] - Previous Release 39 | - Basic MCP server functionality 40 | - Vectara RAG integration -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to vectara-mcp 2 | 3 | Thank you for your interest in Vectara-mcp and considering contributing to our project! 4 | Whether it's a bug, a new tool, updates to the READ or anything else - we truly appreciate your time and effort. 5 | 6 | This document provides guidelines and best practices to help you contribute effectively. 7 | 8 | ## Getting Started 9 | 10 | 1. Fork the repository and clone your fork. 11 | 2. Create a new branch for your changes (e.g. `bug-fix-1234`) 12 | 3. Make your changes in the new branch and test. 13 | 4. Commit and push your changes to your fork. Add useful comments to describe your changes. 14 | 6. Create a pull request following the guidelines in the [Submitting Pull Requests](#submitting-pull-requests) section. 15 | 16 | ## How to Contribute 17 | 18 | ### Reporting Bugs 19 | 20 | If you find a bug in the project, please create an issue on GitHub with the following information: 21 | 22 | - A clear, descriptive title for the issue. 23 | - A description of the problem, including steps to reproduce the issue. 24 | - Any relevant logs, screenshots, or other supporting information. 25 | 26 | ### Suggesting Enhancements 27 | 28 | If you have an idea for a new feature or improvement, please create an issue on GitHub with the following information: 29 | 30 | - A clear, descriptive title for the issue. 31 | - A detailed description of the proposed enhancement, including any benefits and potential drawbacks. 32 | - Any relevant examples, mockups, or supporting information. 33 | 34 | ### Submitting Pull Requests 35 | 36 | When submitting a pull request, please ensure that your changes meet the following criteria: 37 | 38 | - Your pull request should be atomic and focus on a single change. 39 | - You should have thoroughly tested your changes with multiple different scenarios. 40 | - You should have considered potential risks and mitigations for your changes. 41 | - You should have documented your changes clearly and comprehensively. 42 | - Please do not include any unrelated or "extra" small tweaks or changes. 43 | -------------------------------------------------------------------------------- /tests/test_connection_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for connection manager and circuit breaker functionality. 3 | """ 4 | 5 | import pytest 6 | import asyncio 7 | import aiohttp 8 | from unittest.mock import AsyncMock, MagicMock, patch 9 | from vectara_mcp.connection_manager import ( 10 | ConnectionManager, 11 | CircuitBreaker, 12 | CircuitState, 13 | get_connection_manager, 14 | cleanup_connections 15 | ) 16 | 17 | 18 | class TestCircuitBreaker: 19 | """Test circuit breaker functionality.""" 20 | 21 | @pytest.mark.asyncio 22 | async def test_circuit_breaker_success(self): 23 | """Test circuit breaker with successful calls.""" 24 | circuit = CircuitBreaker(failure_threshold=3, recovery_timeout=1) 25 | 26 | async def successful_func(): 27 | return "success" 28 | 29 | result = await circuit.call(successful_func) 30 | assert result == "success" 31 | assert circuit.state == CircuitState.CLOSED 32 | assert circuit.failure_count == 0 33 | 34 | @pytest.mark.asyncio 35 | async def test_circuit_breaker_failure_threshold(self): 36 | """Test circuit breaker opening after failure threshold.""" 37 | circuit = CircuitBreaker(failure_threshold=2, recovery_timeout=1) 38 | 39 | async def failing_func(): 40 | raise aiohttp.ClientError("Test error") 41 | 42 | # First failure 43 | with pytest.raises(aiohttp.ClientError): 44 | await circuit.call(failing_func) 45 | assert circuit.state == CircuitState.CLOSED 46 | assert circuit.failure_count == 1 47 | 48 | # Second failure - should open circuit 49 | with pytest.raises(aiohttp.ClientError): 50 | await circuit.call(failing_func) 51 | assert circuit.state == CircuitState.OPEN 52 | assert circuit.failure_count == 2 53 | 54 | # Third call should fail fast 55 | with pytest.raises(Exception, match="Circuit breaker OPEN"): 56 | await circuit.call(failing_func) 57 | 58 | @pytest.mark.asyncio 59 | async def test_circuit_breaker_recovery(self): 60 | """Test circuit breaker recovery after timeout.""" 61 | circuit = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1) 62 | 63 | async def failing_func(): 64 | raise aiohttp.ClientError("Test error") 65 | 66 | async def successful_func(): 67 | return "success" 68 | 69 | # Trigger circuit opening 70 | with pytest.raises(aiohttp.ClientError): 71 | await circuit.call(failing_func) 72 | assert circuit.state == CircuitState.OPEN 73 | 74 | # Wait for recovery timeout 75 | await asyncio.sleep(0.2) 76 | 77 | # Should transition to half-open and then closed on success 78 | result = await circuit.call(successful_func) 79 | assert result == "success" 80 | assert circuit.state == CircuitState.CLOSED 81 | assert circuit.failure_count == 0 82 | 83 | def test_circuit_breaker_state_info(self): 84 | """Test circuit breaker state information.""" 85 | circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=60) 86 | 87 | state = circuit.get_state() 88 | assert state["state"] == "closed" 89 | assert state["failure_count"] == 0 90 | assert state["failure_threshold"] == 5 91 | assert state["recovery_timeout"] == 60 92 | assert state["last_failure_time"] is None -------------------------------------------------------------------------------- /tests/test_api_key_management.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from unittest.mock import AsyncMock, patch, MagicMock 4 | import os 5 | import aiohttp 6 | from mcp.server.fastmcp import Context 7 | 8 | from vectara_mcp.server import ( 9 | setup_vectara_api_key, 10 | clear_vectara_api_key 11 | ) 12 | 13 | 14 | class TestApiKeyManagement: 15 | """Test suite for API key management tools""" 16 | 17 | @pytest.fixture 18 | def mock_context(self): 19 | """Create a mock context for testing""" 20 | context = AsyncMock(spec=Context) 21 | context.info = MagicMock() 22 | context.report_progress = AsyncMock() 23 | return context 24 | 25 | @pytest.fixture(autouse=True) 26 | def clear_stored_api_key(self): 27 | """Clear stored API key before each test""" 28 | import vectara_mcp.server 29 | vectara_mcp.server._stored_api_key = None 30 | yield 31 | vectara_mcp.server._stored_api_key = None 32 | 33 | @pytest.mark.asyncio 34 | async def test_setup_vectara_api_key_missing_key(self, mock_context): 35 | """Test setup_vectara_api_key with missing API key""" 36 | result = await setup_vectara_api_key( 37 | api_key="", 38 | ctx=mock_context 39 | ) 40 | assert result == "API key is required." 41 | 42 | @pytest.mark.asyncio 43 | @patch('vectara_mcp.server._make_api_request') 44 | async def test_setup_vectara_api_key_invalid_key(self, mock_api_request, mock_context): 45 | """Test setup_vectara_api_key with invalid API key (401 response)""" 46 | mock_api_request.side_effect = Exception("API error 401: API key error") 47 | 48 | result = await setup_vectara_api_key( 49 | api_key="invalid-key", 50 | ctx=mock_context 51 | ) 52 | 53 | assert result == "Invalid API key. Please check your Vectara API key and try again." 54 | 55 | @pytest.mark.asyncio 56 | @patch('vectara_mcp.server._make_api_request') 57 | async def test_setup_vectara_api_key_success(self, mock_api_request, mock_context): 58 | """Test successful setup_vectara_api_key call""" 59 | mock_api_request.side_effect = Exception("Corpus not found") # Valid API key but corpus doesn't exist 60 | 61 | result = await setup_vectara_api_key( 62 | api_key="valid-api-key-12345", 63 | ctx=mock_context 64 | ) 65 | 66 | assert "API key configured successfully: vali***2345" in result 67 | mock_context.info.assert_called_once() 68 | 69 | @pytest.mark.asyncio 70 | @patch('vectara_mcp.server._make_api_request') 71 | async def test_setup_vectara_api_key_network_error(self, mock_api_request, mock_context): 72 | """Test setup_vectara_api_key with network error""" 73 | mock_api_request.side_effect = Exception("Network error") 74 | 75 | result = await setup_vectara_api_key( 76 | api_key="test-key", 77 | ctx=mock_context 78 | ) 79 | 80 | assert result == "API validation failed: Network error" 81 | 82 | @pytest.mark.asyncio 83 | async def test_clear_vectara_api_key(self, mock_context): 84 | """Test clear_vectara_api_key""" 85 | # First set an API key 86 | import vectara_mcp.server 87 | vectara_mcp.server._stored_api_key = "test-key" 88 | 89 | result = await clear_vectara_api_key(ctx=mock_context) 90 | 91 | assert result == "API key cleared from server memory." 92 | assert vectara_mcp.server._stored_api_key is None 93 | mock_context.info.assert_called_once_with("Clearing stored Vectara API key") 94 | 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## General: 6 | - Don't always be so agreeable with me; I need you to be critical and get to the right solution more than make me feel like I'm always right 7 | - When solving a problem, always provide strong evidence for your hypotheses before suggesting a fix 8 | - Be comprehensive and thorough when assessing an issue, bug or problem. base your conclusions in evidence. 9 | - Think carefully and only action the specific task I have given you with the most concise and elegant solution that changes as little code as possible. 10 | - Channel your inner Jeff Dean and his skills to create awesome code 11 | - Always refer to me as "Mr. Ofer" 12 | 13 | ## Coding rules: 14 | - Code must always be efficient and your testing thorough, just like Jeff Dean would work 15 | - KISS (Keep It Simple, Straightforward). When two designs solve the same problem, choose the one with fewer moving parts. 16 | - Avoid premature optimization. First make it work, then measure, then make it fast if the numbers say you must. 17 | - DRY: Don't Repeat Yourself when duplication risks divergent fixes or bugs. 18 | - Prioritize simpler and shorter code even if it takes more thinking to arrive at the best solution. 19 | - When fixing a bug, make sure to identify the root cause and fix that, and avoid generating workarounds that are more complex. 20 | - For python code, follow formatting best practices to ensure pylint passes 21 | 22 | ## Testing 23 | - Before implementing a new feature or functionality, always add a unit test or regression test first, and confirm with me that it clearly defines the new features. That will help us work together and align on specification. 24 | - Verify the code you generate does not introduce any security issues or vulnerabilities 25 | 26 | ## Development Commands 27 | 28 | ### Testing 29 | - Run all tests: `python -m pytest tests/ -v` 30 | - Run integration tests: `python -m pytest tests/test_integration.py -v -s` 31 | - Run unit tests: `python -m pytest tests/test_server.py -v` 32 | - Run specific integration test: `python -m pytest tests/test_integration.py::TestVectaraIntegration::test_all_endpoints_and_analyze_responses -v -s` 33 | 34 | ### Running the Server 35 | - Start MCP server: `python -m vectara_mcp` 36 | - Alternative: `python vectara_mcp/__main__.py` 37 | 38 | ### Environment Setup 39 | - Install dependencies: `pip install -e .` 40 | - Install test dependencies: `pip install -e .[test]` 41 | - Create `.env` file with `VECTARA_API_KEY` and `VECTARA_CORPUS_KEYS` for integration tests 42 | 43 | ## Architecture Overview 44 | 45 | This is a Model Context Protocol (MCP) server that provides RAG capabilities using Vectara's API. The architecture consists of: 46 | 47 | ### Core Components 48 | - **vectara_mcp/server.py**: Main MCP server implementation using FastMCP framework 49 | - **vectara_mcp/__main__.py**: Entry point that starts the server 50 | - **tests/**: Integration and unit tests for all MCP tools 51 | 52 | ### MCP Tools Architecture 53 | The server exposes 4 main MCP tools: 54 | 55 | 1. **ask_vectara**: Full RAG with generation - queries Vectara and returns AI-generated summary with citations 56 | 2. **search_vectara**: Semantic search only - returns ranked search results without generation 57 | 3. **correct_hallucinations**: Uses Vectara's VHC API to identify and correct hallucinations in generated text 58 | 4. **eval_factual_consistency**: Evaluates factual consistency using VHC API for evaluation metrics 59 | 60 | ### Key Design Patterns 61 | - All tools use async/await pattern with FastMCP Context for progress reporting 62 | - Shared utility functions for parameter validation, error handling, and API calls 63 | - Consistent error message formatting across all tools using `_format_error()` 64 | - Tools validate required parameters using shared validation functions 65 | - Unified HTTP error handling with context-specific messages 66 | 67 | ### Vectara Integration 68 | - Uses direct HTTP calls to Vectara API endpoints for all operations (no SDK dependency) 69 | - Multi-corpus support via the `/v2/query` API endpoint 70 | - Configurable search parameters: lexical interpolation, context sentences, reranking 71 | - Direct HTTP calls to VHC API endpoints for hallucination correction/evaluation 72 | 73 | ### Testing Strategy 74 | - Integration tests require real API credentials via `.env` file 75 | - Tests are skipped automatically if credentials are missing 76 | - Mock contexts used to test MCP-specific functionality 77 | - Tests validate both successful responses and error handling -------------------------------------------------------------------------------- /vectara_mcp/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authentication middleware for Vectara MCP Server. 3 | 4 | Provides bearer token validation for HTTP/SSE transports. 5 | """ 6 | 7 | import os 8 | import logging 9 | import time 10 | from typing import Optional 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class AuthMiddleware: 16 | """Authentication middleware for HTTP transport.""" 17 | 18 | def __init__(self, auth_required: bool = True): 19 | """Initialize authentication middleware. 20 | 21 | Args: 22 | auth_required: Whether authentication is required (default: True) 23 | """ 24 | self.auth_required = auth_required 25 | self.valid_tokens = self._load_valid_tokens() 26 | 27 | def _load_valid_tokens(self) -> set: 28 | """Load valid API tokens from environment. 29 | 30 | Returns: 31 | Set of valid bearer tokens 32 | """ 33 | tokens = set() 34 | 35 | # Add main API key if configured 36 | api_key = os.getenv("VECTARA_API_KEY") 37 | if api_key: 38 | tokens.add(api_key) 39 | 40 | # Add additional authorized tokens (comma-separated) 41 | additional_tokens = os.getenv("VECTARA_AUTHORIZED_TOKENS", "") 42 | if additional_tokens: 43 | tokens.update(token.strip() for token in additional_tokens.split(",") if token.strip()) 44 | 45 | return tokens 46 | 47 | def validate_token(self, token: Optional[str]) -> bool: 48 | """Validate a bearer token. 49 | 50 | Args: 51 | token: Bearer token to validate 52 | 53 | Returns: 54 | True if token is valid, False otherwise 55 | """ 56 | if not self.auth_required: 57 | return True 58 | 59 | if not token: 60 | logger.warning("No authentication token provided") 61 | return False 62 | 63 | # Remove "Bearer " prefix if present 64 | if token.startswith("Bearer "): 65 | token = token[7:] 66 | 67 | if token in self.valid_tokens: 68 | return True 69 | 70 | logger.warning("Invalid authentication token") 71 | return False 72 | 73 | def extract_token_from_headers(self, headers: dict) -> Optional[str]: 74 | """Extract bearer token from request headers. 75 | 76 | Args: 77 | headers: Request headers dictionary 78 | 79 | Returns: 80 | Bearer token if found, None otherwise 81 | """ 82 | # Check Authorization header 83 | auth_header = headers.get("Authorization") or headers.get("authorization") 84 | if auth_header: 85 | return auth_header 86 | 87 | # Check X-API-Key header (alternative) 88 | api_key_header = headers.get("X-API-Key") or headers.get("x-api-key") 89 | if api_key_header: 90 | return f"Bearer {api_key_header}" 91 | 92 | return None 93 | 94 | 95 | class RateLimiter: # pylint: disable=too-few-public-methods 96 | """Simple in-memory rate limiter for API endpoints.""" 97 | 98 | def __init__(self, max_requests: int = 100, window_seconds: int = 60): 99 | """Initialize rate limiter. 100 | 101 | Args: 102 | max_requests: Maximum requests per window (default: 100) 103 | window_seconds: Time window in seconds (default: 60) 104 | """ 105 | self.max_requests = max_requests 106 | self.window_seconds = window_seconds 107 | self.requests = {} 108 | 109 | def is_allowed(self, client_id: str) -> bool: 110 | """Check if client is allowed to make a request. 111 | 112 | Args: 113 | client_id: Client identifier (IP address or token) 114 | 115 | Returns: 116 | True if request is allowed, False if rate limited 117 | """ 118 | current_time = time.time() 119 | 120 | if client_id not in self.requests: 121 | self.requests[client_id] = [] 122 | 123 | # Remove old requests outside the window 124 | self.requests[client_id] = [ 125 | timestamp for timestamp in self.requests[client_id] 126 | if current_time - timestamp < self.window_seconds 127 | ] 128 | 129 | # Check if limit exceeded 130 | if len(self.requests[client_id]) >= self.max_requests: 131 | logger.warning("Rate limit exceeded for client: %s", client_id) 132 | return False 133 | 134 | # Add current request 135 | self.requests[client_id].append(current_time) 136 | return True 137 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy and Guidelines for Vectara MCP Server 2 | 3 | The Vectara trust and security center, including our security policy, can be found at 4 | [https://vectara.com/legal/security-at-vectara/](https://vectara.com/legal/security-at-vectara/). 5 | 6 | ## Reporting a Vulnerability 7 | 8 | Please send security vulnerability reports to security@vectara.com. 9 | 10 | --- 11 | 12 | ## MCP Server Security Guidelines 13 | 14 | ### Overview 15 | 16 | The Vectara MCP Server prioritizes security with a "secure by default" approach. This document outlines security best practices, transport layer considerations, and deployment guidelines. 17 | 18 | ### Transport Security Comparison 19 | 20 | #### HTTP/SSE Transport (Default - Recommended) 21 | ✅ **Advantages:** 22 | - Transport-layer encryption (HTTPS) 23 | - Bearer token authentication 24 | - Rate limiting protection 25 | - CORS origin validation 26 | - Session management with cryptographic IDs 27 | - Audit logging capabilities 28 | 29 | ⚠️ **Considerations:** 30 | - Requires proper TLS certificate configuration 31 | - Network-exposed endpoints need firewall rules 32 | - Token management overhead 33 | 34 | #### STDIO Transport (Local Development Only) 35 | ⚠️ **Security Risks:** 36 | - No transport-layer authentication 37 | - API keys visible in process memory 38 | - Credentials may leak to shell history 39 | - No encryption between processes 40 | - Vulnerable to local privilege escalation 41 | 42 | ✅ **Acceptable Use Cases:** 43 | - Local development environments 44 | - Claude Desktop (isolated desktop application) 45 | - CI/CD testing pipelines (isolated containers) 46 | 47 | ### Authentication 48 | 49 | #### Bearer Token Authentication (HTTP/SSE) 50 | 51 | The server validates bearer tokens from multiple sources: 52 | 53 | 1. **Authorization Header** (Recommended) 54 | ```bash 55 | Authorization: Bearer 56 | ``` 57 | 58 | 2. **X-API-Key Header** (Alternative) 59 | ```bash 60 | X-API-Key: 61 | ``` 62 | 63 | #### Token Management 64 | 65 | ```bash 66 | # Primary API key (used for both Vectara API and MCP auth) 67 | export VECTARA_API_KEY="vaa_xxxxxxxxxxxxx" 68 | 69 | # Additional authorized tokens (comma-separated) 70 | export VECTARA_AUTHORIZED_TOKENS="token1,token2,token3" 71 | ``` 72 | 73 | #### Disabling Authentication 74 | 75 | ⚠️ **WARNING**: Never disable authentication in production! 76 | 77 | ```bash 78 | # Development only - creates security vulnerability 79 | python -m vectara_mcp --no-auth 80 | ``` 81 | 82 | ### CORS Configuration 83 | 84 | #### Default Configuration 85 | ```bash 86 | # Restricts to localhost by default 87 | VECTARA_ALLOWED_ORIGINS="http://localhost:*" 88 | ``` 89 | 90 | #### Production Configuration 91 | ```bash 92 | # Whitelist specific domains 93 | VECTARA_ALLOWED_ORIGINS="https://app.example.com,https://admin.example.com" 94 | ``` 95 | 96 | #### Security Headers 97 | 98 | The server automatically adds these security headers: 99 | - `X-Content-Type-Options: nosniff` 100 | - `X-Frame-Options: DENY` 101 | - `X-XSS-Protection: 1; mode=block` 102 | - `Strict-Transport-Security: max-age=31536000` 103 | - `Content-Security-Policy: default-src 'self'` 104 | 105 | ### Rate Limiting 106 | 107 | Default: 100 requests per minute per client 108 | 109 | The built-in rate limiter prevents: 110 | - DoS attacks 111 | - Resource exhaustion 112 | - API abuse 113 | 114 | ### Production Deployment Checklist 115 | 116 | #### 1. Use HTTPS (Required) 117 | 118 | Deploy behind a reverse proxy with TLS: 119 | 120 | ```nginx 121 | server { 122 | listen 443 ssl http2; 123 | server_name api.example.com; 124 | 125 | ssl_certificate /path/to/cert.pem; 126 | ssl_certificate_key /path/to/key.pem; 127 | 128 | location / { 129 | proxy_pass http://127.0.0.1:8000; 130 | proxy_set_header Authorization $http_authorization; 131 | proxy_set_header X-Real-IP $remote_addr; 132 | } 133 | } 134 | ``` 135 | 136 | #### 2. Environment Variables 137 | 138 | ```bash 139 | # Production environment file (.env.production) 140 | VECTARA_API_KEY="vaa_production_key" 141 | VECTARA_AUTHORIZED_TOKENS="client1_token,client2_token" 142 | VECTARA_ALLOWED_ORIGINS="https://app.example.com" 143 | VECTARA_AUTH_REQUIRED="true" 144 | ``` 145 | 146 | #### 3. Network Security 147 | 148 | ```bash 149 | # Firewall rules (iptables example) 150 | # Allow HTTPS only 151 | iptables -A INPUT -p tcp --dport 443 -j ACCEPT 152 | # Block direct access to MCP port 153 | iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT 154 | iptables -A INPUT -p tcp --dport 8000 -j DROP 155 | ``` 156 | 157 | #### 4. Container Security (Docker) 158 | 159 | ```dockerfile 160 | FROM python:3.11-slim 161 | 162 | # Run as non-root user 163 | RUN useradd -m -u 1000 mcp-user 164 | USER mcp-user 165 | 166 | # Copy application 167 | WORKDIR /app 168 | COPY --chown=mcp-user:mcp-user . . 169 | 170 | # Install dependencies 171 | RUN pip install --user vectara-mcp 172 | 173 | # Use secrets at runtime (not build time) 174 | CMD ["python", "-m", "vectara_mcp"] 175 | ``` 176 | 177 | ```yaml 178 | # docker-compose.yml 179 | version: '3.8' 180 | services: 181 | vectara-mcp: 182 | image: vectara-mcp:latest 183 | environment: 184 | - VECTARA_API_KEY=${VECTARA_API_KEY} 185 | secrets: 186 | - vectara_tokens 187 | ports: 188 | - "127.0.0.1:8000:8000" 189 | restart: unless-stopped 190 | 191 | secrets: 192 | vectara_tokens: 193 | external: true 194 | ``` 195 | 196 | ### Common Security Mistakes to Avoid 197 | 198 | #### ❌ DON'T: Expose STDIO to Network 199 | ```bash 200 | # NEVER DO THIS 201 | socat TCP-LISTEN:8080,fork EXEC:"python -m vectara_mcp --stdio" 202 | ``` 203 | 204 | #### ❌ DON'T: Disable Auth in Production 205 | ```bash 206 | # NEVER DO THIS IN PRODUCTION 207 | python -m vectara_mcp --no-auth --host 0.0.0.0 208 | ``` 209 | 210 | #### ❌ DON'T: Store Keys in Code 211 | ```python 212 | # NEVER DO THIS 213 | API_KEY = "vaa_hardcoded_key_12345" 214 | ``` 215 | 216 | #### ❌ DON'T: Use Wildcard CORS 217 | ```bash 218 | # AVOID THIS 219 | VECTARA_ALLOWED_ORIGINS="*" 220 | ``` 221 | 222 | ### Security Incident Response 223 | 224 | If you suspect a security breach: 225 | 226 | 1. **Immediately rotate all API keys** 227 | ```bash 228 | # Revoke compromised keys in Vectara Console 229 | # Generate new keys 230 | # Update VECTARA_API_KEY and VECTARA_AUTHORIZED_TOKENS 231 | ``` 232 | 233 | 2. **Review audit logs** 234 | ```bash 235 | grep "401\|403" /var/log/vectara-mcp/audit.log 236 | ``` 237 | 238 | 3. **Check for unauthorized access** 239 | ```bash 240 | # Review unique IPs 241 | awk '{print $1}' access.log | sort -u 242 | ``` 243 | 244 | 4. **Update and patch** 245 | ```bash 246 | pip install --upgrade vectara-mcp 247 | ``` 248 | 249 | ### Compliance Considerations 250 | 251 | #### Data Privacy 252 | - No user queries or responses are stored by default 253 | - API keys are only held in memory (not persisted) 254 | - No telemetry or analytics collection 255 | 256 | #### Regulatory Compliance 257 | - Supports audit logging for compliance requirements 258 | - Compatible with SOC2, HIPAA deployment patterns 259 | - Allows data residency configuration via corpus selection 260 | 261 | ### Regular Security Maintenance 262 | 263 | #### Weekly 264 | - Review authentication logs 265 | - Check for unusual access patterns 266 | - Verify rate limiting is functioning 267 | 268 | #### Monthly 269 | - Rotate API tokens 270 | - Update dependencies: `pip list --outdated` 271 | - Review CORS and firewall rules 272 | 273 | #### Quarterly 274 | - Security audit of deployment 275 | - Penetration testing (if applicable) 276 | - Update TLS certificates before expiry 277 | 278 | ### Additional Resources 279 | 280 | - [OWASP Security Guidelines](https://owasp.org/) 281 | - [MCP Security Best Practices](https://modelcontextprotocol.io/docs/concepts/security) 282 | - [Vectara Security Documentation](https://docs.vectara.com/docs/learn/security) 283 | 284 | --- 285 | 286 | Remember: Security is not a one-time configuration but an ongoing process. Stay vigilant and keep your systems updated. -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_asyncio 3 | import os 4 | import json 5 | from dotenv import load_dotenv 6 | from mcp.server.fastmcp import Context 7 | from unittest.mock import AsyncMock, MagicMock 8 | 9 | from vectara_mcp.server import ( 10 | ask_vectara, 11 | search_vectara, 12 | correct_hallucinations, 13 | eval_factual_consistency 14 | ) 15 | 16 | # Load environment variables 17 | load_dotenv() 18 | 19 | # Test configuration 20 | API_KEY = os.getenv("VECTARA_API_KEY") 21 | CORPUS_KEYS = os.getenv("VECTARA_CORPUS_KEYS", "").split(",") if os.getenv("VECTARA_CORPUS_KEYS") else [] 22 | TEST_TEXT = os.getenv("TEST_TEXT", "The capital of France is Berlin. The Eiffel Tower is located in London.") 23 | TEST_SOURCE_DOCS = os.getenv("TEST_SOURCE_DOCS", "Paris is the capital of France. The Eiffel Tower is located in Paris, France.|London is the capital of the United Kingdom.").split("|") 24 | 25 | # Skip integration tests if no API key provided 26 | pytestmark = pytest.mark.skipif( 27 | not API_KEY or not CORPUS_KEYS or CORPUS_KEYS == [""], 28 | reason="Integration tests require VECTARA_API_KEY and VECTARA_CORPUS_KEYS in .env file" 29 | ) 30 | 31 | 32 | class TestVectaraIntegration: 33 | """Integration tests for Vectara MCP tools using real API endpoints""" 34 | 35 | @pytest_asyncio.fixture(autouse=True) 36 | async def cleanup_connection_manager(self): 37 | """Cleanup connection manager before and after each test""" 38 | from vectara_mcp.connection_manager import ConnectionManager, connection_manager 39 | # Clean up before test 40 | await connection_manager.close() 41 | ConnectionManager.reset_instance() 42 | yield 43 | # Clean up after test 44 | await connection_manager.close() 45 | ConnectionManager.reset_instance() 46 | 47 | @pytest.fixture 48 | def mock_context(self): 49 | """Create a mock context for testing""" 50 | context = AsyncMock(spec=Context) 51 | context.info = MagicMock() # Non-async mock to avoid coroutine warnings 52 | context.report_progress = AsyncMock() # Keep async since this is actually async 53 | return context 54 | 55 | @pytest.mark.asyncio 56 | async def test_ask_vectara_integration(self, mock_context): 57 | """Test ask_vectara with real API to determine response format""" 58 | if not API_KEY or not CORPUS_KEYS: 59 | pytest.skip("Missing API credentials") 60 | 61 | query = "What is the main topic of this corpus?" 62 | 63 | # Set API key in environment since integration tests need it 64 | import vectara_mcp.server 65 | vectara_mcp.server._stored_api_key = API_KEY 66 | 67 | result = await ask_vectara( 68 | query=query, 69 | ctx=mock_context, 70 | corpus_keys=CORPUS_KEYS, 71 | max_used_search_results=5 72 | ) 73 | 74 | # Print result for analysis 75 | print(f"\n=== ask_vectara result type: {type(result)} ===") 76 | print(f"Result: {result}") 77 | print("=" * 50) 78 | 79 | # Basic validation 80 | assert isinstance(result, dict) 81 | assert "summary" in result 82 | assert "citations" in result 83 | assert "error" not in result 84 | 85 | return result 86 | 87 | @pytest.mark.asyncio 88 | async def test_search_vectara_integration(self, mock_context): 89 | """Test search_vectara with real API to determine response format""" 90 | if not API_KEY or not CORPUS_KEYS: 91 | pytest.skip("Missing API credentials") 92 | 93 | query = "main topics" 94 | 95 | # Set API key in environment since integration tests need it 96 | import vectara_mcp.server 97 | vectara_mcp.server._stored_api_key = API_KEY 98 | 99 | result = await search_vectara( 100 | query=query, 101 | ctx=mock_context, 102 | corpus_keys=CORPUS_KEYS 103 | ) 104 | 105 | # Print result for analysis 106 | print(f"\n=== search_vectara result type: {type(result)} ===") 107 | print(f"Result: {result}") 108 | print("=" * 50) 109 | 110 | # Basic validation 111 | assert isinstance(result, dict) 112 | assert "search_results" in result 113 | assert "error" not in result 114 | 115 | return result 116 | 117 | @pytest.mark.asyncio 118 | async def test_correct_hallucinations_integration(self, mock_context): 119 | """Test correct_hallucinations with real API to determine response format""" 120 | if not API_KEY: 121 | pytest.skip("Missing API key") 122 | 123 | # Set API key in environment since integration tests need it 124 | import vectara_mcp.server 125 | vectara_mcp.server._stored_api_key = API_KEY 126 | 127 | result = await correct_hallucinations( 128 | generated_text=TEST_TEXT, 129 | documents=TEST_SOURCE_DOCS, 130 | ctx=mock_context 131 | ) 132 | 133 | # Print result for analysis 134 | print(f"\n=== correct_hallucinations result type: {type(result)} ===") 135 | print(f"Result: {result}") 136 | print("=" * 50) 137 | 138 | # Basic validation - VHC functions now return dict 139 | assert isinstance(result, dict) 140 | assert "error" not in result 141 | 142 | print(f"Result structure: {json.dumps(result, indent=2)}") 143 | 144 | return result 145 | 146 | @pytest.mark.asyncio 147 | async def test_eval_factual_consistency_integration(self, mock_context): 148 | """Test eval_factual_consistency with real API to determine response format""" 149 | if not API_KEY: 150 | pytest.skip("Missing API key") 151 | 152 | # Set API key in environment since integration tests need it 153 | import vectara_mcp.server 154 | vectara_mcp.server._stored_api_key = API_KEY 155 | 156 | result = await eval_factual_consistency( 157 | generated_text=TEST_TEXT, 158 | documents=TEST_SOURCE_DOCS, 159 | ctx=mock_context 160 | ) 161 | 162 | # Print result for analysis 163 | print(f"\n=== eval_factual_consistency result type: {type(result)} ===") 164 | print(f"Result: {result}") 165 | print("=" * 50) 166 | 167 | # Basic validation - VHC functions now return dict 168 | assert isinstance(result, dict) 169 | assert "error" not in result 170 | 171 | print(f"Result structure: {json.dumps(result, indent=2)}") 172 | 173 | return result 174 | 175 | @pytest.mark.asyncio 176 | async def test_all_endpoints_and_analyze_responses(self, mock_context): 177 | """Run all endpoints and analyze response formats for docstring updates""" 178 | if not API_KEY: 179 | pytest.skip("Missing API key") 180 | 181 | print("\n" + "="*80) 182 | print("COMPREHENSIVE RESPONSE FORMAT ANALYSIS") 183 | print("="*80) 184 | 185 | # Test all endpoints 186 | results = {} 187 | 188 | if CORPUS_KEYS and CORPUS_KEYS != [""]: 189 | print("\n--- Testing ask_vectara ---") 190 | results['ask_vectara'] = await self.test_ask_vectara_integration(mock_context) 191 | 192 | print("\n--- Testing search_vectara ---") 193 | results['search_vectara'] = await self.test_search_vectara_integration(mock_context) 194 | else: 195 | print("\nSkipping corpus-based tests (no corpus keys)") 196 | 197 | print("\n--- Testing correct_hallucinations ---") 198 | results['correct_hallucinations'] = await self.test_correct_hallucinations_integration(mock_context) 199 | 200 | print("\n--- Testing eval_factual_consistency ---") 201 | results['eval_factual_consistency'] = await self.test_eval_factual_consistency_integration(mock_context) 202 | 203 | # Generate docstring recommendations 204 | print("\n" + "="*80) 205 | print("DOCSTRING UPDATE RECOMMENDATIONS") 206 | print("="*80) 207 | 208 | for tool_name, result in results.items(): 209 | print(f"\n--- {tool_name} ---") 210 | if isinstance(result, dict): 211 | if "error" in result: 212 | print(f"❌ API Error: {result['error']}") 213 | else: 214 | print(f"✅ Returns dict with structure:") 215 | print(f" Keys: {list(result.keys())}") 216 | for key, value in result.items(): 217 | print(f" - {key}: {type(value).__name__}") 218 | else: 219 | print(f"⚠️ Unexpected return type: {type(result).__name__}") 220 | print(f" Value: {result}") 221 | 222 | print("\n" + "="*80) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vectara MCP Server 2 | 3 | ![GitHub Repo stars](https://img.shields.io/github/stars/Vectara/Vectara-mcp?style=social) 4 | ![PyPI version](https://img.shields.io/pypi/v/vectara-mcp.svg) 5 | ![License](https://img.shields.io/pypi/l/vectara-mcp.svg) 6 | ![Security](https://img.shields.io/badge/security-first-brightgreen) 7 | 8 | > 🔌 **Compatible with [Claude Desktop](https://claude.ai/desktop), and any other MCP Client!** 9 | > 10 | > Vectara MCP is also compatible with any MCP client 11 | > 12 | 13 | The Model Context Protocol (MCP) is an open standard that enables AI systems to interact seamlessly with various data sources and tools, facilitating secure, two-way connections. 14 | 15 | Vectara-MCP provides any agentic application with access to fast, reliable RAG with reduced hallucination, powered by Vectara's Trusted RAG platform, through the MCP protocol. 16 | 17 | ## Installation 18 | 19 | You can install the package directly from PyPI: 20 | 21 | ```bash 22 | pip install vectara-mcp 23 | ``` 24 | 25 | ## Quick Start 26 | 27 | ### Secure by Default (HTTP/SSE with Authentication) 28 | 29 | ```bash 30 | # Start server with secure HTTP transport (DEFAULT) 31 | python -m vectara_mcp 32 | # Server running at http://127.0.0.1:8000 with authentication enabled 33 | ``` 34 | 35 | ### Local Development Mode (STDIO) 36 | 37 | ```bash 38 | # For Claude Desktop or local development (less secure) 39 | python -m vectara_mcp --stdio 40 | # ⚠️ Warning: STDIO transport is less secure. Use only for local development. 41 | ``` 42 | 43 | ### Configuration Options 44 | 45 | ```bash 46 | # Custom host and port 47 | python -m vectara_mcp --host 0.0.0.0 --port 8080 48 | 49 | # SSE transport mode 50 | python -m vectara_mcp --transport sse --path /sse 51 | 52 | # Disable authentication (DANGEROUS - dev only) 53 | python -m vectara_mcp --no-auth 54 | ``` 55 | 56 | ## Transport Modes 57 | 58 | ### HTTP Transport (Default - Recommended) 59 | - **Security:** Built-in authentication via bearer tokens 60 | - **Encryption:** HTTPS ready 61 | - **Rate Limiting:** 100 requests/minute by default 62 | - **CORS Protection:** Configurable origin validation 63 | - **Use Case:** Production deployments, cloud environments 64 | 65 | ### SSE Transport 66 | - **Streaming:** Server-Sent Events for real-time updates 67 | - **Authentication:** Bearer token support 68 | - **Compatibility:** Works with legacy MCP clients 69 | - **Use Case:** Real-time streaming applications 70 | 71 | ### STDIO Transport 72 | - **⚠️ Security Warning:** No transport-layer security 73 | - **Performance:** Low latency for local communication 74 | - **Use Case:** Local development, Claude Desktop 75 | - **Requirement:** Must be explicitly enabled with `--stdio` flag 76 | 77 | ## Environment Variables 78 | 79 | ```bash 80 | # Required 81 | export VECTARA_API_KEY="your-api-key" 82 | 83 | # Optional 84 | export VECTARA_AUTHORIZED_TOKENS="token1,token2" # Additional auth tokens 85 | export VECTARA_ALLOWED_ORIGINS="http://localhost:*,https://app.example.com" 86 | export VECTARA_TRANSPORT="http" # Default transport mode 87 | export VECTARA_AUTH_REQUIRED="true" # Enforce authentication 88 | ``` 89 | 90 | ## Authentication 91 | 92 | ### HTTP/SSE Transport 93 | When using HTTP or SSE transport, authentication is required by default: 94 | 95 | ```bash 96 | # Using curl with bearer token 97 | curl -H "Authorization: Bearer $VECTARA_API_KEY" \ 98 | -H "Content-Type: application/json" \ 99 | -X POST http://localhost:8000/call/ask_vectara \ 100 | -d '{"query": "What is Vectara?", "corpus_keys": ["my-corpus"]}' 101 | 102 | # Using X-API-Key header (alternative) 103 | curl -H "X-API-Key: $VECTARA_API_KEY" \ 104 | http://localhost:8000/sse 105 | ``` 106 | 107 | ### Disabling Authentication (Development Only) 108 | ```bash 109 | # ⚠️ NEVER use in production 110 | python -m vectara_mcp --no-auth 111 | ``` 112 | 113 | ## Available Tools 114 | 115 | ### API Key Management 116 | - **setup_vectara_api_key:** 117 | Configure and validate your Vectara API key for the session (one-time setup). 118 | 119 | Args: 120 | - api_key: str, Your Vectara API key - required. 121 | 122 | Returns: 123 | - Success confirmation with masked API key or validation error. 124 | 125 | 126 | - **clear_vectara_api_key:** 127 | Clear the stored API key from server memory. 128 | 129 | Returns: 130 | - Confirmation message. 131 | 132 | ### Query Tools 133 | - **ask_vectara:** 134 | Run a RAG query using Vectara, returning search results with a generated response. 135 | 136 | Args: 137 | - query: str, The user query to run - required. 138 | - corpus_keys: list[str], List of Vectara corpus keys to use for the search - required. 139 | - n_sentences_before: int, Number of sentences before the answer to include in the context - optional, default is 2. 140 | - n_sentences_after: int, Number of sentences after the answer to include in the context - optional, default is 2. 141 | - lexical_interpolation: float, The amount of lexical interpolation to use - optional, default is 0.005. 142 | - max_used_search_results: int, The maximum number of search results to use - optional, default is 10. 143 | - generation_preset_name: str, The name of the generation preset to use - optional, default is "vectara-summary-table-md-query-ext-jan-2025-gpt-4o". 144 | - response_language: str, The language of the response - optional, default is "eng". 145 | 146 | Returns: 147 | - The response from Vectara, including the generated answer and the search results. 148 | 149 | - **search_vectara:** 150 | Run a semantic search query using Vectara, without generation. 151 | 152 | Args: 153 | - query: str, The user query to run - required. 154 | - corpus_keys: list[str], List of Vectara corpus keys to use for the search - required. 155 | - n_sentences_before: int, Number of sentences before the answer to include in the context - optional, default is 2. 156 | - n_sentences_after: int, Number of sentences after the answer to include in the context - optional, default is 2. 157 | - lexical_interpolation: float, The amount of lexical interpolation to use - optional, default is 0.005. 158 | 159 | Returns: 160 | - The response from Vectara, including the matching search results. 161 | 162 | ### Analysis Tools 163 | - **correct_hallucinations:** 164 | Identify and correct hallucinations in generated text using Vectara's VHC (Vectara Hallucination Correction) API. 165 | 166 | Args: 167 | - generated_text: str, The generated text to analyze for hallucinations - required. 168 | - documents: list[str], List of source documents to compare against - required. 169 | - query: str, The original user query that led to the generated text - optional. 170 | 171 | Returns: 172 | - JSON-formatted string containing corrected text and detailed correction information. 173 | 174 | - **eval_factual_consistency:** 175 | Evaluate the factual consistency of generated text against source documents using Vectara's dedicated factual consistency evaluation API. 176 | 177 | Args: 178 | - generated_text: str, The generated text to evaluate for factual consistency - required. 179 | - documents: list[str], List of source documents to compare against - required. 180 | - query: str, The original user query that led to the generated text - optional. 181 | 182 | Returns: 183 | - JSON-formatted string containing factual consistency evaluation results and scoring. 184 | 185 | **Note:** API key must be configured first using `setup_vectara_api_key` tool or `VECTARA_API_KEY` environment variable. 186 | 187 | 188 | ## Configuration with Claude Desktop 189 | 190 | To use with Claude Desktop, update your configuration to use STDIO transport: 191 | 192 | ```json 193 | { 194 | "mcpServers": { 195 | "Vectara": { 196 | "command": "python", 197 | "args": ["-m", "vectara_mcp", "--stdio"], 198 | "env": { 199 | "VECTARA_API_KEY": "your-api-key" 200 | } 201 | } 202 | } 203 | } 204 | ``` 205 | 206 | Or using uv: 207 | 208 | ```json 209 | { 210 | "mcpServers": { 211 | "Vectara": { 212 | "command": "uv", 213 | "args": ["tool", "run", "vectara-mcp", "--stdio"] 214 | } 215 | } 216 | } 217 | ``` 218 | 219 | **Note:** Claude Desktop requires STDIO transport. While less secure than HTTP, it's acceptable for local desktop use. 220 | 221 | ## Usage in Claude Desktop App 222 | 223 | Once the installation is complete, and the Claude desktop app is configured, you must completely close and re-open the Claude desktop app to see the Vectara-mcp server. You should see a hammer icon in the bottom left of the app, indicating available MCP tools, you can click on the hammer icon to see more detail on the Vectara-search and Vectara-extract tools. 224 | 225 | Now claude will have complete access to the Vectara-mcp server, including all six Vectara tools. 226 | 227 | ## Secure Setup Workflow 228 | 229 | **First-time setup (one-time per session):** 230 | 1. Configure your API key securely: 231 | ``` 232 | setup-vectara-api-key 233 | API key: [your-vectara-api-key] 234 | ``` 235 | 236 | 237 | **After setup, use any tools without exposing your API key:** 238 | 239 | ### Vectara Tool Examples 240 | 241 | 1. **RAG Query with Generation**: 242 | ``` 243 | ask-vectara 244 | Query: Who is Amr Awadallah? 245 | Corpus keys: ["your-corpus-key"] 246 | ``` 247 | 248 | 2. **Semantic Search Only**: 249 | ``` 250 | search-vectara 251 | Query: events in NYC? 252 | Corpus keys: ["your-corpus-key"] 253 | ``` 254 | 255 | 3. **Hallucination Detection & Correction**: 256 | ``` 257 | correct-hallucinations 258 | Generated text: [text to check] 259 | Documents: ["source1", "source2"] 260 | ``` 261 | 262 | 4. **Factual Consistency Evaluation**: 263 | ``` 264 | eval-factual-consistency 265 | Generated text: [text to evaluate] 266 | Documents: ["reference1", "reference2"] 267 | ``` 268 | 269 | ## Security Best Practices 270 | 271 | 1. **Always use HTTP transport for production** - Never expose STDIO transport to the network 272 | 2. **Keep authentication enabled** - Only disable with `--no-auth` for local testing 273 | 3. **Use HTTPS in production** - Deploy behind a reverse proxy with TLS termination 274 | 4. **Configure CORS properly** - Set `VECTARA_ALLOWED_ORIGINS` to restrict access 275 | 5. **Rotate API keys regularly** - Update `VECTARA_API_KEY` and `VECTARA_AUTHORIZED_TOKENS` 276 | 6. **Monitor rate limits** - Default 100 req/min, adjust based on your needs 277 | 278 | See [SECURITY.md](SECURITY.md) for detailed security guidelines. 279 | 280 | ## Support 281 | 282 | For issues, questions, or contributions, please visit: 283 | https://github.com/vectara/vectara-mcp -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /vectara_mcp/connection_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connection management and resilience patterns for Vectara MCP Server. 3 | 4 | Provides persistent connection pooling and circuit breaker pattern 5 | for reliable communication with Vectara API. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | import ssl 11 | import time 12 | from enum import Enum 13 | from typing import Optional, Dict, Any 14 | 15 | import aiohttp 16 | from tenacity import ( 17 | AsyncRetrying, 18 | stop_after_attempt, 19 | wait_exponential, 20 | retry_if_exception_type 21 | ) 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | # Connection timeout constants 26 | DEFAULT_TOTAL_TIMEOUT = 30 # Total request timeout 27 | DEFAULT_CONNECT_TIMEOUT = 10 # Connection timeout 28 | DEFAULT_SOCK_READ_TIMEOUT = 20 # Socket read timeout 29 | DEFAULT_HEALTH_CHECK_TIMEOUT = 5 # Health check timeout 30 | 31 | # Circuit breaker constants 32 | DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5 33 | DEFAULT_CIRCUIT_RECOVERY_TIMEOUT = 60 34 | 35 | 36 | class CircuitState(Enum): 37 | """Circuit breaker states.""" 38 | CLOSED = "closed" # Normal operation 39 | OPEN = "open" # Failing, reject requests 40 | HALF_OPEN = "half_open" # Testing recovery 41 | 42 | 43 | class CircuitBreaker: 44 | """Circuit breaker pattern implementation for API resilience.""" 45 | 46 | def __init__( 47 | self, 48 | failure_threshold: int = DEFAULT_CIRCUIT_FAILURE_THRESHOLD, 49 | recovery_timeout: int = DEFAULT_CIRCUIT_RECOVERY_TIMEOUT, 50 | expected_exception: tuple = (aiohttp.ClientError, asyncio.TimeoutError) 51 | ): 52 | """Initialize circuit breaker. 53 | 54 | Args: 55 | failure_threshold: Number of failures before opening circuit 56 | recovery_timeout: Seconds to wait before attempting recovery 57 | expected_exception: Exception types that trigger circuit opening 58 | """ 59 | self.failure_threshold = failure_threshold 60 | self.recovery_timeout = recovery_timeout 61 | self.expected_exception = expected_exception 62 | 63 | self.failure_count = 0 64 | self.last_failure_time = None 65 | self.state = CircuitState.CLOSED 66 | self._lock = asyncio.Lock() 67 | 68 | async def call(self, func, *args, **kwargs): 69 | """Execute function with circuit breaker protection. 70 | 71 | Args: 72 | func: Async function to execute 73 | *args: Function arguments 74 | **kwargs: Function keyword arguments 75 | 76 | Returns: 77 | Function result 78 | 79 | Raises: 80 | Exception: If circuit is open or function fails 81 | """ 82 | async with self._lock: 83 | if self.state == CircuitState.OPEN: 84 | if self._should_attempt_reset(): 85 | self.state = CircuitState.HALF_OPEN 86 | logger.info("Circuit breaker transitioning to HALF_OPEN") 87 | else: 88 | raise RuntimeError( 89 | f"Circuit breaker OPEN. Last failure: {self.last_failure_time}" 90 | ) 91 | 92 | try: 93 | result = await func(*args, **kwargs) 94 | await self._on_success() 95 | return result 96 | except self.expected_exception: 97 | await self._on_failure() 98 | raise 99 | except Exception: # pylint: disable=broad-exception-caught 100 | # Unexpected exceptions don't trigger circuit breaker 101 | logger.warning("Unexpected exception in circuit breaker", exc_info=True) 102 | raise 103 | 104 | def _should_attempt_reset(self) -> bool: 105 | """Check if enough time has passed to attempt reset.""" 106 | if self.last_failure_time is None: 107 | return True 108 | return time.time() - self.last_failure_time >= self.recovery_timeout 109 | 110 | async def _on_success(self): 111 | """Handle successful execution.""" 112 | async with self._lock: 113 | if self.state == CircuitState.HALF_OPEN: 114 | self.state = CircuitState.CLOSED 115 | logger.info("Circuit breaker reset to CLOSED") 116 | self.failure_count = 0 117 | 118 | async def _on_failure(self): 119 | """Handle failed execution.""" 120 | async with self._lock: 121 | self.failure_count += 1 122 | self.last_failure_time = time.time() 123 | 124 | if self.failure_count >= self.failure_threshold: 125 | self.state = CircuitState.OPEN 126 | logger.warning( 127 | "Circuit breaker OPEN after %d failures", self.failure_count 128 | ) 129 | 130 | def get_state(self) -> Dict[str, Any]: 131 | """Get current circuit breaker state for monitoring.""" 132 | return { 133 | "state": self.state.value, 134 | "failure_count": self.failure_count, 135 | "last_failure_time": self.last_failure_time, 136 | "failure_threshold": self.failure_threshold, 137 | "recovery_timeout": self.recovery_timeout 138 | } 139 | 140 | 141 | class ConnectionManager: 142 | """Manages persistent HTTP connections for Vectara API.""" 143 | 144 | _instance: Optional['ConnectionManager'] = None 145 | _lock = asyncio.Lock() 146 | 147 | def __new__(cls): 148 | """Singleton pattern implementation.""" 149 | if cls._instance is None: 150 | cls._instance = super().__new__(cls) 151 | return cls._instance 152 | 153 | def __init__(self): 154 | """Initialize connection manager.""" 155 | if hasattr(self, '_initialized'): 156 | return 157 | 158 | self._session: Optional[aiohttp.ClientSession] = None 159 | self._circuit_breaker = CircuitBreaker() 160 | self._session_loop: Optional[asyncio.AbstractEventLoop] = None 161 | self._initialized = True 162 | 163 | # Connection pool configuration 164 | self._connector_config = { 165 | 'limit': 100, # Total connection limit 166 | 'limit_per_host': 30, # Connections per host 167 | 'ttl_dns_cache': 300, # DNS cache TTL 168 | 'use_dns_cache': True, 169 | 'keepalive_timeout': 30, 170 | 'enable_cleanup_closed': True 171 | } 172 | 173 | # Request timeout configuration 174 | self._timeout_config = aiohttp.ClientTimeout( 175 | total=DEFAULT_TOTAL_TIMEOUT, 176 | connect=DEFAULT_CONNECT_TIMEOUT, 177 | sock_read=DEFAULT_SOCK_READ_TIMEOUT, 178 | ) 179 | 180 | async def initialize(self): 181 | """Initialize the HTTP session.""" 182 | current_loop = asyncio.get_running_loop() 183 | 184 | # Check if session exists and is bound to a different/closed event loop 185 | if self._session is not None: 186 | if self._session_loop != current_loop or self._session.closed: 187 | logger.info("Session bound to different/closed event loop, reinitializing") 188 | await self._close_session() 189 | else: 190 | return 191 | 192 | async with self._lock: 193 | # Double-check after acquiring lock 194 | session_valid = ( 195 | self._session is not None 196 | and self._session_loop == current_loop 197 | and not self._session.closed 198 | ) 199 | if session_valid: 200 | return 201 | 202 | # Close existing session if it exists 203 | if self._session is not None: 204 | await self._close_session() 205 | 206 | # Create SSL context with verification 207 | ssl_context = ssl.create_default_context() 208 | ssl_context.check_hostname = True 209 | ssl_context.verify_mode = ssl.CERT_REQUIRED 210 | 211 | # Create TCP connector with configuration 212 | connector = aiohttp.TCPConnector( 213 | ssl=ssl_context, 214 | **self._connector_config 215 | ) 216 | 217 | # Create session with connector and timeout 218 | self._session = aiohttp.ClientSession( 219 | connector=connector, 220 | timeout=self._timeout_config, 221 | headers={ 222 | 'User-Agent': 'Vectara-MCP-Server/2.0', 223 | 'Accept': 'application/json', 224 | 'Accept-Encoding': 'gzip, deflate' 225 | } 226 | ) 227 | self._session_loop = current_loop 228 | 229 | logger.info("Connection manager initialized with persistent session") 230 | 231 | async def _close_session(self): 232 | """Helper method to safely close the current session.""" 233 | if self._session is not None: 234 | try: 235 | await self._session.close() 236 | except RuntimeError as e: 237 | if "Event loop is closed" not in str(e): 238 | raise 239 | # Silently handle event loop closure during cleanup 240 | finally: 241 | self._session = None 242 | self._session_loop = None 243 | 244 | async def close(self): 245 | """Close the HTTP session and cleanup resources.""" 246 | await self._close_session() 247 | logger.info("Connection manager closed") 248 | 249 | @classmethod 250 | def reset_instance(cls): 251 | """Reset the singleton instance. Use with caution - mainly for testing.""" 252 | cls._instance = None 253 | 254 | 255 | async def request( 256 | self, 257 | method: str, 258 | url: str, 259 | headers: Optional[Dict[str, str]] = None, 260 | json_data: Optional[Dict[str, Any]] = None, 261 | **kwargs 262 | ) -> aiohttp.ClientResponse: 263 | """Make HTTP request with circuit breaker protection and retry logic. 264 | 265 | Args: 266 | method: HTTP method (GET, POST, etc.) 267 | url: Request URL 268 | headers: Request headers 269 | json_data: JSON payload 270 | **kwargs: Additional aiohttp parameters 271 | 272 | Returns: 273 | aiohttp.ClientResponse: HTTP response 274 | 275 | Raises: 276 | Exception: If circuit is open or request fails after retries 277 | """ 278 | await self.initialize() 279 | 280 | if self._session is None: 281 | raise RuntimeError("Session not initialized") 282 | 283 | if self._session.closed: 284 | raise RuntimeError("Session has been closed") 285 | 286 | async def _make_request_with_circuit_breaker(): 287 | """Make request through circuit breaker.""" 288 | async def _make_request(): 289 | response = await self._session.request( 290 | method=method, 291 | url=url, 292 | headers=headers, 293 | json=json_data, 294 | **kwargs 295 | ) 296 | 297 | # Check for HTTP errors that should trigger circuit breaker 298 | if response.status >= 500: 299 | raise aiohttp.ClientResponseError( 300 | request_info=response.request_info, 301 | history=response.history, 302 | status=response.status, 303 | message=f"HTTP {response.status}" 304 | ) 305 | 306 | return response 307 | 308 | return await self._circuit_breaker.call(_make_request) 309 | 310 | # Apply retry logic with circuit breaker using tenacity 311 | async for attempt in AsyncRetrying( 312 | stop=stop_after_attempt(3), 313 | wait=wait_exponential(multiplier=1, min=1, max=10), 314 | retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)) 315 | ): 316 | with attempt: 317 | return await _make_request_with_circuit_breaker() 318 | 319 | def get_stats(self) -> Dict[str, Any]: 320 | """Get connection and circuit breaker statistics.""" 321 | stats = { 322 | "session_initialized": self._session is not None, 323 | "circuit_breaker": self._circuit_breaker.get_state(), 324 | "connector_config": self._connector_config, 325 | } 326 | 327 | if self._session and hasattr(self._session.connector, '_conns'): 328 | # Get connection pool stats if available 329 | # pylint: disable=protected-access 330 | try: 331 | connector = self._session.connector 332 | stats["connection_pool"] = { 333 | "total_connections": len(connector._conns), 334 | "available_connections": sum( 335 | len(conns) for conns in connector._conns.values() 336 | ) 337 | } 338 | except (AttributeError, TypeError): 339 | # Connector stats not available in this aiohttp version 340 | pass 341 | # pylint: enable=protected-access 342 | 343 | return stats 344 | 345 | async def health_check(self, url: str = "https://api.vectara.io/v2") -> Dict[str, Any]: 346 | """Perform health check on Vectara API. 347 | 348 | Args: 349 | url: Base URL to check 350 | 351 | Returns: 352 | Dict with health check results 353 | """ 354 | start_time = time.time() 355 | 356 | try: 357 | health_url = f"{url}/health" 358 | response = await self.request( 359 | 'GET', health_url, timeout=DEFAULT_HEALTH_CHECK_TIMEOUT 360 | ) 361 | duration = time.time() - start_time 362 | 363 | return { 364 | "status": "healthy", 365 | "response_time_ms": round(duration * 1000, 2), 366 | "status_code": response.status, 367 | "circuit_breaker_state": self._circuit_breaker.state.value 368 | } 369 | except Exception as e: # pylint: disable=broad-exception-caught 370 | duration = time.time() - start_time 371 | return { 372 | "status": "unhealthy", 373 | "error": str(e), 374 | "response_time_ms": round(duration * 1000, 2), 375 | "circuit_breaker_state": self._circuit_breaker.state.value 376 | } 377 | 378 | 379 | # Global connection manager instance 380 | connection_manager = ConnectionManager() 381 | 382 | 383 | async def get_connection_manager() -> ConnectionManager: 384 | """Get the global connection manager instance. 385 | 386 | Returns: 387 | ConnectionManager: Singleton instance 388 | """ 389 | await connection_manager.initialize() 390 | return connection_manager 391 | 392 | 393 | async def cleanup_connections(): 394 | """Cleanup function for graceful shutdown.""" 395 | await connection_manager.close() 396 | -------------------------------------------------------------------------------- /vectara_mcp/health_checks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Health check endpoints for Vectara MCP Server. 3 | 4 | Provides liveness, readiness, and detailed health status endpoints 5 | for production deployment with load balancers and orchestration platforms. 6 | """ 7 | 8 | import logging 9 | import os 10 | import time 11 | from dataclasses import dataclass 12 | from enum import Enum 13 | from typing import Any, Dict, Optional 14 | 15 | from .connection_manager import get_connection_manager 16 | from ._version import __version__ 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class HealthStatus(Enum): 22 | """Health check status values.""" 23 | HEALTHY = "healthy" 24 | UNHEALTHY = "unhealthy" 25 | DEGRADED = "degraded" 26 | UNKNOWN = "unknown" 27 | 28 | 29 | @dataclass 30 | class HealthCheck: 31 | """Individual health check result.""" 32 | name: str 33 | status: HealthStatus 34 | message: str 35 | response_time_ms: Optional[float] = None 36 | details: Optional[Dict[str, Any]] = None 37 | 38 | 39 | class HealthChecker: 40 | """Manages health checks for the MCP server.""" 41 | 42 | def __init__(self): 43 | """Initialize health checker.""" 44 | self.server_start_time = time.time() 45 | self.last_check_cache = {} 46 | self.cache_ttl = 5 # Cache health checks for 5 seconds 47 | 48 | async def liveness_check(self) -> Dict[str, Any]: 49 | """Basic liveness check - is the server process running and responding? 50 | 51 | This should be fast and only check if the process is alive. 52 | Used by load balancers to determine if traffic should be routed here. 53 | 54 | Returns: 55 | Dict: Liveness status 56 | """ 57 | return { 58 | "status": HealthStatus.HEALTHY.value, 59 | "timestamp": time.time(), 60 | "uptime_seconds": round(time.time() - self.server_start_time, 2), 61 | "version": __version__, 62 | "service": "vectara-mcp-server" 63 | } 64 | 65 | async def readiness_check(self) -> Dict[str, Any]: 66 | """Readiness check - can the server handle traffic? 67 | 68 | Checks critical dependencies that must be working for the server 69 | to properly handle requests. Used by orchestration platforms. 70 | 71 | Returns: 72 | Dict: Readiness status with dependency checks 73 | """ 74 | checks = [] 75 | overall_status = HealthStatus.HEALTHY 76 | start_time = time.time() 77 | 78 | # Check connection manager 79 | try: 80 | connection_check = await self._check_connection_manager() 81 | checks.append(connection_check) 82 | if connection_check.status != HealthStatus.HEALTHY: 83 | overall_status = HealthStatus.UNHEALTHY 84 | except Exception as e: # pylint: disable=broad-exception-caught 85 | checks.append(HealthCheck( 86 | name="connection_manager", 87 | status=HealthStatus.UNHEALTHY, 88 | message=f"Connection manager check failed: {str(e)}" 89 | )) 90 | overall_status = HealthStatus.UNHEALTHY 91 | 92 | # Check Vectara API connectivity 93 | try: 94 | vectara_check = await self._check_vectara_connectivity() 95 | checks.append(vectara_check) 96 | if vectara_check.status == HealthStatus.UNHEALTHY: 97 | overall_status = HealthStatus.UNHEALTHY 98 | elif (vectara_check.status == HealthStatus.DEGRADED 99 | and overall_status == HealthStatus.HEALTHY): 100 | overall_status = HealthStatus.DEGRADED 101 | except Exception as e: # pylint: disable=broad-exception-caught 102 | checks.append(HealthCheck( 103 | name="vectara_api", 104 | status=HealthStatus.UNHEALTHY, 105 | message=f"Vectara API check failed: {str(e)}" 106 | )) 107 | overall_status = HealthStatus.UNHEALTHY 108 | 109 | total_time = round((time.time() - start_time) * 1000, 2) 110 | 111 | return { 112 | "status": overall_status.value, 113 | "timestamp": time.time(), 114 | "response_time_ms": total_time, 115 | "checks": [ 116 | { 117 | "name": check.name, 118 | "status": check.status.value, 119 | "message": check.message, 120 | "response_time_ms": check.response_time_ms, 121 | "details": check.details 122 | } 123 | for check in checks 124 | ] 125 | } 126 | 127 | async def detailed_health_check(self) -> Dict[str, Any]: 128 | """Comprehensive health check with all system components. 129 | 130 | Provides detailed information about all system components, 131 | metrics, and configuration. Used for monitoring and debugging. 132 | 133 | Returns: 134 | Dict: Detailed health status 135 | """ 136 | checks = [] 137 | metrics = {} 138 | overall_status = HealthStatus.HEALTHY 139 | start_time = time.time() 140 | 141 | # Basic server info 142 | server_info = { 143 | "uptime_seconds": round(time.time() - self.server_start_time, 2), 144 | "version": __version__, 145 | "service": "vectara-mcp-server", 146 | "pid": os.getpid() if hasattr(os, 'getpid') else None 147 | } 148 | 149 | # Connection manager health 150 | try: 151 | connection_check = await self._check_connection_manager_detailed() 152 | checks.append(connection_check) 153 | if connection_check.status != HealthStatus.HEALTHY: 154 | overall_status = HealthStatus.DEGRADED 155 | except Exception as e: # pylint: disable=broad-exception-caught 156 | checks.append(HealthCheck( 157 | name="connection_manager_detailed", 158 | status=HealthStatus.UNHEALTHY, 159 | message=f"Detailed connection check failed: {str(e)}" 160 | )) 161 | overall_status = HealthStatus.UNHEALTHY 162 | 163 | # Vectara API connectivity 164 | try: 165 | vectara_check = await self._check_vectara_connectivity() 166 | checks.append(vectara_check) 167 | if vectara_check.status == HealthStatus.UNHEALTHY: 168 | overall_status = HealthStatus.UNHEALTHY 169 | elif (vectara_check.status == HealthStatus.DEGRADED 170 | and overall_status == HealthStatus.HEALTHY): 171 | overall_status = HealthStatus.DEGRADED 172 | except Exception as e: # pylint: disable=broad-exception-caught 173 | checks.append(HealthCheck( 174 | name="vectara_api_detailed", 175 | status=HealthStatus.UNHEALTHY, 176 | message=f"Vectara API detailed check failed: {str(e)}" 177 | )) 178 | overall_status = HealthStatus.UNHEALTHY 179 | 180 | # Memory usage (if available) 181 | try: 182 | import psutil # pylint: disable=import-outside-toplevel 183 | process = psutil.Process() 184 | metrics["memory"] = { 185 | "rss_mb": round(process.memory_info().rss / 1024 / 1024, 2), 186 | "vms_mb": round(process.memory_info().vms / 1024 / 1024, 2), 187 | "percent": round(process.memory_percent(), 2) 188 | } 189 | except ImportError: 190 | metrics["memory"] = {"error": "psutil not available"} 191 | except Exception as e: # pylint: disable=broad-exception-caught 192 | metrics["memory"] = {"error": str(e)} 193 | 194 | total_time = round((time.time() - start_time) * 1000, 2) 195 | 196 | return { 197 | "status": overall_status.value, 198 | "timestamp": time.time(), 199 | "response_time_ms": total_time, 200 | "server": server_info, 201 | "checks": [ 202 | { 203 | "name": check.name, 204 | "status": check.status.value, 205 | "message": check.message, 206 | "response_time_ms": check.response_time_ms, 207 | "details": check.details 208 | } 209 | for check in checks 210 | ], 211 | "metrics": metrics 212 | } 213 | 214 | async def _check_connection_manager(self) -> HealthCheck: 215 | """Check connection manager basic health.""" 216 | start_time = time.time() 217 | 218 | try: 219 | manager = await get_connection_manager() 220 | stats = manager.get_stats() 221 | 222 | response_time = round((time.time() - start_time) * 1000, 2) 223 | 224 | if stats["session_initialized"]: 225 | return HealthCheck( 226 | name="connection_manager", 227 | status=HealthStatus.HEALTHY, 228 | message="Connection manager initialized and ready", 229 | response_time_ms=response_time, 230 | details={"circuit_breaker_state": stats["circuit_breaker"]["state"]} 231 | ) 232 | return HealthCheck( 233 | name="connection_manager", 234 | status=HealthStatus.UNHEALTHY, 235 | message="Connection manager not initialized", 236 | response_time_ms=response_time 237 | ) 238 | 239 | except Exception as e: # pylint: disable=broad-exception-caught 240 | response_time = round((time.time() - start_time) * 1000, 2) 241 | return HealthCheck( 242 | name="connection_manager", 243 | status=HealthStatus.UNHEALTHY, 244 | message=f"Connection manager error: {str(e)}", 245 | response_time_ms=response_time 246 | ) 247 | 248 | async def _check_connection_manager_detailed(self) -> HealthCheck: 249 | """Check connection manager detailed health.""" 250 | start_time = time.time() 251 | 252 | try: 253 | manager = await get_connection_manager() 254 | stats = manager.get_stats() 255 | 256 | response_time = round((time.time() - start_time) * 1000, 2) 257 | 258 | circuit_state = stats["circuit_breaker"]["state"] 259 | failure_count = stats["circuit_breaker"]["failure_count"] 260 | 261 | if stats["session_initialized"]: 262 | if circuit_state == "open": 263 | status = HealthStatus.UNHEALTHY 264 | message = f"Circuit breaker OPEN with {failure_count} failures" 265 | elif circuit_state == "half_open": 266 | status = HealthStatus.DEGRADED 267 | message = "Circuit breaker testing recovery" 268 | elif failure_count > 0: 269 | status = HealthStatus.DEGRADED 270 | message = f"Recent failures: {failure_count}" 271 | else: 272 | status = HealthStatus.HEALTHY 273 | message = "Connection manager healthy" 274 | 275 | return HealthCheck( 276 | name="connection_manager_detailed", 277 | status=status, 278 | message=message, 279 | response_time_ms=response_time, 280 | details=stats 281 | ) 282 | return HealthCheck( 283 | name="connection_manager_detailed", 284 | status=HealthStatus.UNHEALTHY, 285 | message="Connection manager not initialized", 286 | response_time_ms=response_time 287 | ) 288 | 289 | except Exception as e: # pylint: disable=broad-exception-caught 290 | response_time = round((time.time() - start_time) * 1000, 2) 291 | return HealthCheck( 292 | name="connection_manager_detailed", 293 | status=HealthStatus.UNHEALTHY, 294 | message=f"Connection manager error: {str(e)}", 295 | response_time_ms=response_time 296 | ) 297 | 298 | async def _check_vectara_connectivity(self) -> HealthCheck: 299 | """Check Vectara API connectivity.""" 300 | cache_key = "vectara_connectivity" 301 | 302 | # Check cache first 303 | if cache_key in self.last_check_cache: 304 | cached_result, cache_time = self.last_check_cache[cache_key] 305 | if time.time() - cache_time < self.cache_ttl: 306 | return cached_result 307 | 308 | start_time = time.time() 309 | 310 | try: 311 | manager = await get_connection_manager() 312 | health_result = await manager.health_check("https://api.vectara.io") 313 | 314 | response_time = round((time.time() - start_time) * 1000, 2) 315 | 316 | if health_result["status"] == "healthy": 317 | status = HealthStatus.HEALTHY 318 | message = f"Vectara API accessible ({health_result['response_time_ms']}ms)" 319 | else: 320 | status = HealthStatus.DEGRADED 321 | message = f"Vectara API issues: {health_result.get('error', 'Unknown error')}" 322 | 323 | result = HealthCheck( 324 | name="vectara_api", 325 | status=status, 326 | message=message, 327 | response_time_ms=response_time, 328 | details={ 329 | "api_response_time_ms": health_result.get("response_time_ms"), 330 | "circuit_breaker_state": health_result.get("circuit_breaker_state") 331 | } 332 | ) 333 | 334 | # Cache the result 335 | self.last_check_cache[cache_key] = (result, time.time()) 336 | return result 337 | 338 | except Exception as e: # pylint: disable=broad-exception-caught 339 | response_time = round((time.time() - start_time) * 1000, 2) 340 | result = HealthCheck( 341 | name="vectara_api", 342 | status=HealthStatus.UNHEALTHY, 343 | message=f"Vectara API connectivity failed: {str(e)}", 344 | response_time_ms=response_time 345 | ) 346 | 347 | # Cache the result 348 | self.last_check_cache[cache_key] = (result, time.time()) 349 | return result 350 | 351 | 352 | # Global health checker instance 353 | health_checker = HealthChecker() 354 | 355 | 356 | # Convenience functions for FastMCP integration 357 | async def get_liveness() -> Dict[str, Any]: 358 | """Get liveness status.""" 359 | return await health_checker.liveness_check() 360 | 361 | 362 | async def get_readiness() -> Dict[str, Any]: 363 | """Get readiness status.""" 364 | return await health_checker.readiness_check() 365 | 366 | 367 | async def get_detailed_health() -> Dict[str, Any]: 368 | """Get detailed health status.""" 369 | return await health_checker.detailed_health_check() 370 | -------------------------------------------------------------------------------- /tests/test_health_checks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for health check functionality. 3 | """ 4 | 5 | import pytest 6 | import time 7 | from unittest.mock import AsyncMock, MagicMock, patch 8 | 9 | from starlette.testclient import TestClient 10 | 11 | from vectara_mcp.health_checks import ( 12 | HealthChecker, 13 | HealthStatus, 14 | HealthCheck, 15 | get_liveness, 16 | get_readiness, 17 | get_detailed_health 18 | ) 19 | 20 | 21 | class TestHealthChecker: 22 | """Test health checker functionality.""" 23 | 24 | @pytest.fixture 25 | def health_checker(self): 26 | """Create a health checker instance for testing.""" 27 | return HealthChecker() 28 | 29 | @pytest.mark.asyncio 30 | async def test_liveness_check(self, health_checker): 31 | """Test basic liveness check.""" 32 | result = await health_checker.liveness_check() 33 | 34 | assert result["status"] == HealthStatus.HEALTHY.value 35 | assert "timestamp" in result 36 | assert "uptime_seconds" in result 37 | assert result["service"] == "vectara-mcp-server" 38 | assert result["uptime_seconds"] >= 0 39 | 40 | @pytest.mark.asyncio 41 | async def test_readiness_check_healthy(self, health_checker): 42 | """Test readiness check with healthy dependencies.""" 43 | with patch.object(health_checker, '_check_connection_manager') as mock_conn: 44 | with patch.object(health_checker, '_check_vectara_connectivity') as mock_vectara: 45 | # Mock healthy responses 46 | mock_conn.return_value = HealthCheck( 47 | name="connection_manager", 48 | status=HealthStatus.HEALTHY, 49 | message="Connection manager healthy", 50 | response_time_ms=10.0 51 | ) 52 | mock_vectara.return_value = HealthCheck( 53 | name="vectara_api", 54 | status=HealthStatus.HEALTHY, 55 | message="Vectara API accessible", 56 | response_time_ms=20.0 57 | ) 58 | 59 | result = await health_checker.readiness_check() 60 | 61 | assert result["status"] == HealthStatus.HEALTHY.value 62 | assert "timestamp" in result 63 | assert "response_time_ms" in result 64 | assert len(result["checks"]) == 2 65 | 66 | # Check individual components 67 | check_names = [check["name"] for check in result["checks"]] 68 | assert "connection_manager" in check_names 69 | assert "vectara_api" in check_names 70 | 71 | @pytest.mark.asyncio 72 | async def test_readiness_check_unhealthy(self, health_checker): 73 | """Test readiness check with unhealthy dependencies.""" 74 | with patch.object(health_checker, '_check_connection_manager') as mock_conn: 75 | with patch.object(health_checker, '_check_vectara_connectivity') as mock_vectara: 76 | # Mock unhealthy connection manager 77 | mock_conn.return_value = HealthCheck( 78 | name="connection_manager", 79 | status=HealthStatus.UNHEALTHY, 80 | message="Connection manager error", 81 | response_time_ms=5.0 82 | ) 83 | mock_vectara.return_value = HealthCheck( 84 | name="vectara_api", 85 | status=HealthStatus.HEALTHY, 86 | message="Vectara API accessible", 87 | response_time_ms=20.0 88 | ) 89 | 90 | result = await health_checker.readiness_check() 91 | 92 | assert result["status"] == HealthStatus.UNHEALTHY.value 93 | assert len(result["checks"]) == 2 94 | 95 | @pytest.mark.asyncio 96 | async def test_readiness_check_degraded(self, health_checker): 97 | """Test readiness check with degraded dependencies.""" 98 | with patch.object(health_checker, '_check_connection_manager') as mock_conn: 99 | with patch.object(health_checker, '_check_vectara_connectivity') as mock_vectara: 100 | # Mock healthy connection but degraded API 101 | mock_conn.return_value = HealthCheck( 102 | name="connection_manager", 103 | status=HealthStatus.HEALTHY, 104 | message="Connection manager healthy", 105 | response_time_ms=10.0 106 | ) 107 | mock_vectara.return_value = HealthCheck( 108 | name="vectara_api", 109 | status=HealthStatus.DEGRADED, 110 | message="Vectara API slow response", 111 | response_time_ms=5000.0 112 | ) 113 | 114 | result = await health_checker.readiness_check() 115 | 116 | assert result["status"] == HealthStatus.DEGRADED.value 117 | 118 | @pytest.mark.asyncio 119 | async def test_detailed_health_check(self, health_checker): 120 | """Test detailed health check.""" 121 | with patch.object(health_checker, '_check_connection_manager_detailed') as mock_conn: 122 | with patch.object(health_checker, '_check_vectara_connectivity') as mock_vectara: 123 | # Mock detailed responses 124 | mock_conn.return_value = HealthCheck( 125 | name="connection_manager_detailed", 126 | status=HealthStatus.HEALTHY, 127 | message="Connection manager healthy", 128 | response_time_ms=15.0, 129 | details={"circuit_breaker_state": "closed"} 130 | ) 131 | mock_vectara.return_value = HealthCheck( 132 | name="vectara_api", 133 | status=HealthStatus.HEALTHY, 134 | message="Vectara API accessible", 135 | response_time_ms=25.0 136 | ) 137 | 138 | result = await health_checker.detailed_health_check() 139 | 140 | assert result["status"] == HealthStatus.HEALTHY.value 141 | assert "server" in result 142 | assert "metrics" in result 143 | assert "checks" in result 144 | assert result["server"]["service"] == "vectara-mcp-server" 145 | 146 | @pytest.mark.asyncio 147 | async def test_connection_manager_check_healthy(self, health_checker): 148 | """Test connection manager health check when healthy.""" 149 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 150 | mock_manager = MagicMock() 151 | mock_manager.get_stats.return_value = { 152 | "session_initialized": True, 153 | "circuit_breaker": {"state": "closed"} 154 | } 155 | mock_get_manager.return_value = mock_manager 156 | 157 | result = await health_checker._check_connection_manager() 158 | 159 | assert result.name == "connection_manager" 160 | assert result.status == HealthStatus.HEALTHY 161 | assert "initialized and ready" in result.message 162 | assert result.response_time_ms is not None 163 | 164 | @pytest.mark.asyncio 165 | async def test_connection_manager_check_unhealthy(self, health_checker): 166 | """Test connection manager health check when unhealthy.""" 167 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 168 | mock_manager = MagicMock() 169 | mock_manager.get_stats.return_value = { 170 | "session_initialized": False, 171 | "circuit_breaker": {"state": "closed"} 172 | } 173 | mock_get_manager.return_value = mock_manager 174 | 175 | result = await health_checker._check_connection_manager() 176 | 177 | assert result.name == "connection_manager" 178 | assert result.status == HealthStatus.UNHEALTHY 179 | assert "not initialized" in result.message 180 | 181 | @pytest.mark.asyncio 182 | async def test_connection_manager_check_exception(self, health_checker): 183 | """Test connection manager health check with exception.""" 184 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 185 | mock_get_manager.side_effect = Exception("Connection failed") 186 | 187 | result = await health_checker._check_connection_manager() 188 | 189 | assert result.name == "connection_manager" 190 | assert result.status == HealthStatus.UNHEALTHY 191 | assert "Connection failed" in result.message 192 | 193 | @pytest.mark.asyncio 194 | async def test_vectara_connectivity_check_healthy(self, health_checker): 195 | """Test Vectara API connectivity check when healthy.""" 196 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 197 | mock_manager = AsyncMock() 198 | mock_manager.health_check.return_value = { 199 | "status": "healthy", 200 | "response_time_ms": 150.0, 201 | "circuit_breaker_state": "closed" 202 | } 203 | mock_get_manager.return_value = mock_manager 204 | 205 | result = await health_checker._check_vectara_connectivity() 206 | 207 | assert result.name == "vectara_api" 208 | assert result.status == HealthStatus.HEALTHY 209 | assert "accessible" in result.message 210 | 211 | @pytest.mark.asyncio 212 | async def test_vectara_connectivity_check_degraded(self, health_checker): 213 | """Test Vectara API connectivity check when degraded.""" 214 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 215 | mock_manager = AsyncMock() 216 | mock_manager.health_check.return_value = { 217 | "status": "unhealthy", 218 | "error": "Timeout", 219 | "circuit_breaker_state": "open" 220 | } 221 | mock_get_manager.return_value = mock_manager 222 | 223 | result = await health_checker._check_vectara_connectivity() 224 | 225 | assert result.name == "vectara_api" 226 | assert result.status == HealthStatus.DEGRADED 227 | assert "issues" in result.message 228 | 229 | @pytest.mark.asyncio 230 | async def test_vectara_connectivity_check_exception(self, health_checker): 231 | """Test Vectara API connectivity check with exception.""" 232 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 233 | mock_get_manager.side_effect = Exception("Network error") 234 | 235 | result = await health_checker._check_vectara_connectivity() 236 | 237 | assert result.name == "vectara_api" 238 | assert result.status == HealthStatus.UNHEALTHY 239 | assert "Network error" in result.message 240 | 241 | @pytest.mark.asyncio 242 | async def test_detailed_connection_manager_check_circuit_open(self, health_checker): 243 | """Test detailed connection manager check with open circuit.""" 244 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 245 | mock_manager = MagicMock() 246 | mock_manager.get_stats.return_value = { 247 | "session_initialized": True, 248 | "circuit_breaker": { 249 | "state": "open", 250 | "failure_count": 5 251 | } 252 | } 253 | mock_get_manager.return_value = mock_manager 254 | 255 | result = await health_checker._check_connection_manager_detailed() 256 | 257 | assert result.name == "connection_manager_detailed" 258 | assert result.status == HealthStatus.UNHEALTHY 259 | assert "OPEN" in result.message 260 | 261 | @pytest.mark.asyncio 262 | async def test_detailed_connection_manager_check_circuit_half_open(self, health_checker): 263 | """Test detailed connection manager check with half-open circuit.""" 264 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 265 | mock_manager = MagicMock() 266 | mock_manager.get_stats.return_value = { 267 | "session_initialized": True, 268 | "circuit_breaker": { 269 | "state": "half_open", 270 | "failure_count": 3 271 | } 272 | } 273 | mock_get_manager.return_value = mock_manager 274 | 275 | result = await health_checker._check_connection_manager_detailed() 276 | 277 | assert result.name == "connection_manager_detailed" 278 | assert result.status == HealthStatus.DEGRADED 279 | assert "testing recovery" in result.message 280 | 281 | @pytest.mark.asyncio 282 | async def test_cache_functionality(self, health_checker): 283 | """Test that connectivity check results are cached.""" 284 | with patch('vectara_mcp.health_checks.get_connection_manager') as mock_get_manager: 285 | mock_manager = AsyncMock() 286 | mock_manager.health_check.return_value = { 287 | "status": "healthy", 288 | "response_time_ms": 100.0, 289 | "circuit_breaker_state": "closed" 290 | } 291 | mock_get_manager.return_value = mock_manager 292 | 293 | # First call 294 | result1 = await health_checker._check_vectara_connectivity() 295 | 296 | # Second call (should use cache) 297 | result2 = await health_checker._check_vectara_connectivity() 298 | 299 | # Should only call the manager once due to caching 300 | assert mock_manager.health_check.call_count == 1 301 | assert result1.status == result2.status 302 | 303 | def test_health_status_enum(self): 304 | """Test HealthStatus enum values.""" 305 | assert HealthStatus.HEALTHY.value == "healthy" 306 | assert HealthStatus.UNHEALTHY.value == "unhealthy" 307 | assert HealthStatus.DEGRADED.value == "degraded" 308 | assert HealthStatus.UNKNOWN.value == "unknown" 309 | 310 | def test_health_check_dataclass(self): 311 | """Test HealthCheck dataclass.""" 312 | check = HealthCheck( 313 | name="test_check", 314 | status=HealthStatus.HEALTHY, 315 | message="Test message", 316 | response_time_ms=100.0, 317 | details={"key": "value"} 318 | ) 319 | 320 | assert check.name == "test_check" 321 | assert check.status == HealthStatus.HEALTHY 322 | assert check.message == "Test message" 323 | assert check.response_time_ms == 100.0 324 | assert check.details == {"key": "value"} 325 | 326 | 327 | class TestHealthCheckEndpoints: 328 | """Test health check endpoint functions.""" 329 | 330 | @pytest.mark.asyncio 331 | async def test_get_liveness(self): 332 | """Test get_liveness function.""" 333 | result = await get_liveness() 334 | 335 | assert result["status"] == HealthStatus.HEALTHY.value 336 | assert "uptime_seconds" in result 337 | assert result["service"] == "vectara-mcp-server" 338 | 339 | @pytest.mark.asyncio 340 | async def test_get_readiness(self): 341 | """Test get_readiness function.""" 342 | with patch('vectara_mcp.health_checks.health_checker') as mock_checker: 343 | async def mock_readiness(): 344 | return { 345 | "status": HealthStatus.HEALTHY.value, 346 | "checks": [] 347 | } 348 | mock_checker.readiness_check = mock_readiness 349 | 350 | result = await get_readiness() 351 | 352 | assert result["status"] == HealthStatus.HEALTHY.value 353 | 354 | @pytest.mark.asyncio 355 | async def test_get_detailed_health(self): 356 | """Test get_detailed_health function.""" 357 | with patch('vectara_mcp.health_checks.health_checker') as mock_checker: 358 | async def mock_detailed(): 359 | return { 360 | "status": HealthStatus.HEALTHY.value, 361 | "checks": [], 362 | "metrics": {} 363 | } 364 | mock_checker.detailed_health_check = mock_detailed 365 | 366 | result = await get_detailed_health() 367 | 368 | assert result["status"] == HealthStatus.HEALTHY.value 369 | 370 | 371 | class TestHTTPHealthEndpoints: 372 | """Test HTTP health check endpoints.""" 373 | 374 | @pytest.fixture 375 | def client(self): 376 | """Create a test client for HTTP endpoints.""" 377 | from vectara_mcp.server import mcp 378 | # Get the SSE app which includes custom routes 379 | app = mcp.sse_app() 380 | return TestClient(app) 381 | 382 | def test_health_endpoint(self, client): 383 | """Test /health endpoint returns liveness status.""" 384 | with patch('vectara_mcp.server.get_liveness') as mock_liveness: 385 | mock_liveness.return_value = { 386 | "status": "healthy", 387 | "uptime_seconds": 100.0, 388 | "version": "1.0.0", 389 | "service": "vectara-mcp-server" 390 | } 391 | 392 | response = client.get("/health") 393 | 394 | assert response.status_code == 200 395 | data = response.json() 396 | assert data["status"] == "healthy" 397 | assert "uptime_seconds" in data 398 | 399 | def test_ready_endpoint_healthy(self, client): 400 | """Test /ready endpoint when healthy.""" 401 | with patch('vectara_mcp.server.get_readiness') as mock_readiness: 402 | mock_readiness.return_value = { 403 | "status": "healthy", 404 | "checks": [] 405 | } 406 | 407 | response = client.get("/ready") 408 | 409 | assert response.status_code == 200 410 | data = response.json() 411 | assert data["status"] == "healthy" 412 | 413 | def test_ready_endpoint_unhealthy(self, client): 414 | """Test /ready endpoint when unhealthy returns 503.""" 415 | with patch('vectara_mcp.server.get_readiness') as mock_readiness: 416 | mock_readiness.return_value = { 417 | "status": "unhealthy", 418 | "checks": [] 419 | } 420 | 421 | response = client.get("/ready") 422 | 423 | assert response.status_code == 503 424 | data = response.json() 425 | assert data["status"] == "unhealthy" 426 | 427 | def test_detailed_health_endpoint(self, client): 428 | """Test /health/detailed endpoint.""" 429 | with patch('vectara_mcp.server.get_detailed_health') as mock_detailed: 430 | mock_detailed.return_value = { 431 | "status": "healthy", 432 | "checks": [], 433 | "metrics": {"memory": {"rss_mb": 50.0}} 434 | } 435 | 436 | response = client.get("/health/detailed") 437 | 438 | assert response.status_code == 200 439 | data = response.json() 440 | assert data["status"] == "healthy" 441 | assert "metrics" in data 442 | 443 | def test_stats_endpoint(self, client): 444 | """Test /stats endpoint.""" 445 | with patch('vectara_mcp.server.connection_manager') as mock_conn: 446 | mock_conn.get_stats.return_value = { 447 | "session_initialized": True, 448 | "circuit_breaker": {"state": "closed"} 449 | } 450 | 451 | response = client.get("/stats") 452 | 453 | assert response.status_code == 200 454 | data = response.json() 455 | assert "connection_manager" in data 456 | assert "server_info" in data -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pytest 3 | import json 4 | import os 5 | import sys 6 | from unittest.mock import AsyncMock, patch, MagicMock 7 | import aiohttp 8 | from mcp.server.fastmcp import Context 9 | 10 | from vectara_mcp.server import ( 11 | ask_vectara, 12 | search_vectara, 13 | correct_hallucinations, 14 | eval_factual_consistency, 15 | main 16 | ) 17 | from vectara_mcp.auth import AuthMiddleware 18 | 19 | 20 | class TestVectaraTools: 21 | """Test suite for Vectara MCP tools with new API key management""" 22 | 23 | @pytest.fixture 24 | def mock_context(self): 25 | """Create a mock context for testing""" 26 | context = AsyncMock(spec=Context) 27 | context.info = MagicMock() 28 | context.report_progress = AsyncMock() 29 | return context 30 | 31 | @pytest.fixture(autouse=True) 32 | def clear_stored_api_key(self): 33 | """Clear stored API key before each test""" 34 | import vectara_mcp.server 35 | vectara_mcp.server._stored_api_key = None 36 | vectara_mcp.server._auth_required = True 37 | yield 38 | vectara_mcp.server._stored_api_key = None 39 | vectara_mcp.server._auth_required = True 40 | 41 | @pytest.fixture 42 | def mock_api_key(self): 43 | """Mock API key storage for tests that need it""" 44 | import vectara_mcp.server 45 | vectara_mcp.server._stored_api_key = "test-api-key" 46 | return "test-api-key" 47 | 48 | # ASK_VECTARA TESTS 49 | @pytest.mark.asyncio 50 | async def test_ask_vectara_missing_query(self, mock_context, mock_api_key): 51 | """Test ask_vectara with missing query""" 52 | result = await ask_vectara( 53 | query="", 54 | ctx=mock_context, 55 | corpus_keys=["test-corpus"] 56 | ) 57 | assert result == {"error": "Query is required."} 58 | 59 | @pytest.mark.asyncio 60 | async def test_ask_vectara_missing_corpus_keys(self, mock_context, mock_api_key): 61 | """Test ask_vectara with missing corpus keys""" 62 | result = await ask_vectara( 63 | query="test query", 64 | ctx=mock_context, 65 | corpus_keys=[] 66 | ) 67 | assert result == {"error": "Corpus keys are required. Please ask the user to provide one or more corpus keys."} 68 | 69 | @pytest.mark.asyncio 70 | @patch.dict('os.environ', {}, clear=True) 71 | async def test_ask_vectara_missing_api_key(self, mock_context): 72 | """Test ask_vectara with missing API key""" 73 | result = await ask_vectara( 74 | query="test query", 75 | ctx=mock_context, 76 | corpus_keys=["test-corpus"] 77 | ) 78 | assert result == {"error": "API key not configured. Please use 'setup_vectara_api_key' tool first or set VECTARA_API_KEY environment variable."} 79 | 80 | @pytest.mark.asyncio 81 | @patch('vectara_mcp.server._call_vectara_query') 82 | async def test_ask_vectara_success(self, mock_api_call, mock_context, mock_api_key): 83 | """Test successful ask_vectara call""" 84 | mock_api_call.return_value = { 85 | "summary": "Test response summary", 86 | "search_results": [ 87 | { 88 | "score": 0.95, 89 | "text": "Test citation text", 90 | "document_metadata": {"title": "Test Source"} 91 | } 92 | ] 93 | } 94 | 95 | result = await ask_vectara( 96 | query="test query", 97 | ctx=mock_context, 98 | corpus_keys=["test-corpus"] 99 | ) 100 | 101 | # Check the structured response format 102 | assert result["summary"] == "Test response summary" 103 | assert "citations" in result 104 | assert len(result["citations"]) == 1 105 | 106 | # Check citation details 107 | citation = result["citations"][0] 108 | assert citation["id"] == 1 109 | assert citation["score"] == 0.95 110 | assert citation["text"] == "Test citation text" 111 | assert citation["document_metadata"] == {"title": "Test Source"} 112 | mock_context.info.assert_called_once_with("Running Vectara RAG query: test query") 113 | mock_api_call.assert_called_once() 114 | 115 | @pytest.mark.asyncio 116 | @patch('vectara_mcp.server._call_vectara_query') 117 | async def test_ask_vectara_exception(self, mock_api_call, mock_context, mock_api_key): 118 | """Test ask_vectara with exception""" 119 | mock_api_call.side_effect = Exception("API Error") 120 | 121 | result = await ask_vectara( 122 | query="test query", 123 | ctx=mock_context, 124 | corpus_keys=["test-corpus"] 125 | ) 126 | 127 | assert result == {"error": "Error with Vectara RAG query: API Error"} 128 | 129 | # SEARCH_VECTARA TESTS 130 | @pytest.mark.asyncio 131 | async def test_search_vectara_missing_query(self, mock_context, mock_api_key): 132 | """Test search_vectara with missing query""" 133 | result = await search_vectara( 134 | query="", 135 | ctx=mock_context, 136 | corpus_keys=["test-corpus"] 137 | ) 138 | assert result == {"error": "Query is required."} 139 | 140 | @pytest.mark.asyncio 141 | @patch('vectara_mcp.server._call_vectara_query') 142 | async def test_search_vectara_success(self, mock_api_call, mock_context, mock_api_key): 143 | """Test successful search_vectara call""" 144 | mock_api_call.return_value = { 145 | "search_results": [ 146 | { 147 | "score": 0.95, 148 | "text": "Test search result text", 149 | "document_metadata": {"title": "Test Document"} 150 | } 151 | ] 152 | } 153 | 154 | result = await search_vectara( 155 | query="test query", 156 | ctx=mock_context, 157 | corpus_keys=["test-corpus"] 158 | ) 159 | 160 | assert isinstance(result, dict) 161 | assert "search_results" in result 162 | assert len(result["search_results"]) == 1 163 | assert result["search_results"][0]["score"] == 0.95 164 | assert result["search_results"][0]["text"] == "Test search result text" 165 | assert result["search_results"][0]["document_metadata"]["title"] == "Test Document" 166 | mock_context.info.assert_called_once_with("Running Vectara semantic search query: test query") 167 | mock_api_call.assert_called_once() 168 | 169 | # TRANSPORT AND AUTH TESTS 170 | def test_auth_middleware_validation(self): 171 | """Test authentication middleware validation""" 172 | auth = AuthMiddleware(auth_required=True) 173 | 174 | # Valid token 175 | os.environ["VECTARA_API_KEY"] = "test-key" 176 | auth.valid_tokens = {"test-key"} 177 | assert auth.validate_token("test-key") is True 178 | assert auth.validate_token("Bearer test-key") is True 179 | 180 | # Invalid token 181 | assert auth.validate_token("invalid-key") is False 182 | assert auth.validate_token(None) is False 183 | 184 | # Auth disabled 185 | auth_disabled = AuthMiddleware(auth_required=False) 186 | assert auth_disabled.validate_token(None) is True 187 | 188 | # Clean up 189 | if "VECTARA_API_KEY" in os.environ: 190 | del os.environ["VECTARA_API_KEY"] 191 | 192 | def test_token_extraction_from_headers(self): 193 | """Test token extraction from different header formats""" 194 | auth = AuthMiddleware() 195 | 196 | # Authorization header 197 | headers = {"Authorization": "Bearer test-token"} 198 | assert auth.extract_token_from_headers(headers) == "Bearer test-token" 199 | 200 | # X-API-Key header 201 | headers = {"X-API-Key": "test-token"} 202 | assert auth.extract_token_from_headers(headers) == "Bearer test-token" 203 | 204 | # Case insensitive 205 | headers = {"authorization": "Bearer test-token"} 206 | assert auth.extract_token_from_headers(headers) == "Bearer test-token" 207 | 208 | # No token 209 | headers = {} 210 | assert auth.extract_token_from_headers(headers) is None 211 | 212 | @patch('sys.argv', ['test', '--transport', 'stdio']) 213 | def test_main_stdio_transport(self, caplog): 214 | """Test main function with STDIO transport""" 215 | with patch('vectara_mcp.server.mcp.run') as mock_run: 216 | with pytest.raises(SystemExit): 217 | main() 218 | 219 | mock_run.assert_called_once_with() 220 | assert "STDIO transport is less secure" in caplog.text 221 | 222 | @patch('sys.argv', ['test']) 223 | def test_main_default_transport(self, caplog): 224 | """Test main function with default transport (SSE)""" 225 | caplog.set_level(logging.INFO) 226 | with patch('vectara_mcp.server.mcp.run') as mock_run: 227 | with pytest.raises(SystemExit): 228 | main() 229 | 230 | # SSE is now the default transport 231 | mock_run.assert_called_once_with(transport='sse', mount_path='/sse/messages') 232 | assert "SSE mode" in caplog.text 233 | assert "Authentication: enabled" in caplog.text 234 | 235 | @patch('sys.argv', ['test', '--transport', 'sse', '--port', '9000', '--host', '0.0.0.0']) 236 | def test_main_sse_transport(self, caplog): 237 | """Test main function with SSE transport and custom host/port""" 238 | caplog.set_level(logging.INFO) 239 | with patch('vectara_mcp.server.mcp.run') as mock_run: 240 | with pytest.raises(SystemExit): 241 | main() 242 | 243 | # FastMCP.run() only accepts transport and mount_path parameters 244 | # Default path is /sse/messages as defined in server.py argparse 245 | mock_run.assert_called_once_with(transport='sse', mount_path='/sse/messages') 246 | assert "SSE mode" in caplog.text 247 | assert "http://0.0.0.0:9000/sse/messages" in caplog.text 248 | 249 | @patch('sys.argv', ['test', '--transport', 'streamable-http', '--port', '9000']) 250 | def test_main_streamable_http_transport(self, caplog): 251 | """Test main function with Streamable HTTP transport""" 252 | caplog.set_level(logging.INFO) 253 | with patch('vectara_mcp.server.mcp.run') as mock_run: 254 | with pytest.raises(SystemExit): 255 | main() 256 | 257 | # Streamable HTTP uses the newer MCP 2025 spec 258 | mock_run.assert_called_once_with(transport='streamable-http') 259 | assert "Streamable HTTP mode" in caplog.text 260 | assert "http://127.0.0.1:9000/mcp" in caplog.text 261 | 262 | @patch('sys.argv', ['test', '--transport', 'sse', '--no-auth']) 263 | def test_main_no_auth_warning(self, caplog): 264 | """Test main function shows warning when auth is disabled. 265 | 266 | Note: --no-auth warning only shows for non-STDIO transports. 267 | STDIO transport exits early with its own security warning. 268 | """ 269 | with patch('vectara_mcp.server.mcp.run') as mock_run: 270 | with pytest.raises(SystemExit): 271 | main() 272 | 273 | assert "Authentication disabled" in caplog.text 274 | assert "NEVER use in production" in caplog.text 275 | 276 | def test_fastmcp_run_parameter_validation(self): 277 | """ 278 | Test that ensures mcp.run() is called with only valid FastMCP parameters. 279 | This test specifically catches the bug where host/port were incorrectly 280 | passed to FastMCP.run() instead of being configured via settings. 281 | """ 282 | from mcp.server.fastmcp import FastMCP 283 | import inspect 284 | 285 | # Verify FastMCP.run() signature - this is the ground truth 286 | run_signature = inspect.signature(FastMCP.run) 287 | valid_params = set(run_signature.parameters.keys()) - {'self'} 288 | 289 | # Expected valid parameters for FastMCP.run() 290 | expected_params = {'transport', 'mount_path'} 291 | assert valid_params == expected_params, f"FastMCP.run() signature changed. Expected {expected_params}, got {valid_params}" 292 | 293 | # Test that streamable-http doesn't try to pass invalid parameters 294 | with patch('sys.argv', ['test', '--transport', 'streamable-http', '--host', '192.168.1.1', '--port', '8080']): 295 | with patch('vectara_mcp.server.mcp.run') as mock_run: 296 | with pytest.raises(SystemExit): 297 | main() 298 | 299 | # Verify only valid parameters are passed 300 | call_args, call_kwargs = mock_run.call_args 301 | invalid_params = set(call_kwargs.keys()) - valid_params 302 | assert not invalid_params, f"Invalid parameters passed to mcp.run(): {invalid_params}" 303 | 304 | # Test SSE transport as well 305 | with patch('sys.argv', ['test', '--transport', 'sse', '--path', '/custom-sse']): 306 | with patch('vectara_mcp.server.mcp.run') as mock_run: 307 | with pytest.raises(SystemExit): 308 | main() 309 | 310 | call_args, call_kwargs = mock_run.call_args 311 | invalid_params = set(call_kwargs.keys()) - valid_params 312 | assert not invalid_params, f"Invalid parameters passed to mcp.run(): {invalid_params}" 313 | 314 | # Verify mount_path is correctly passed for SSE 315 | assert call_kwargs.get('mount_path') == '/custom-sse' 316 | 317 | # ENVIRONMENT VARIABLES TESTS 318 | @patch.dict('os.environ', {'VECTARA_TRANSPORT': 'sse', 'VECTARA_AUTH_REQUIRED': 'false'}, clear=False) 319 | def test_environment_variables(self): 320 | """Test that environment variables are respected""" 321 | # This test would require integration with actual argument parsing 322 | # For now, just test that the environment variables exist 323 | assert os.getenv('VECTARA_TRANSPORT') == 'sse' 324 | assert os.getenv('VECTARA_AUTH_REQUIRED') == 'false' 325 | 326 | # CORRECT_HALLUCINATIONS TESTS 327 | @pytest.mark.asyncio 328 | async def test_correct_hallucinations_missing_text(self, mock_context, mock_api_key): 329 | """Test correct_hallucinations with missing text""" 330 | result = await correct_hallucinations( 331 | generated_text="", 332 | documents=["doc1"], 333 | ctx=mock_context 334 | ) 335 | assert result == {"error": "Generated text is required."} 336 | 337 | @pytest.mark.asyncio 338 | async def test_correct_hallucinations_missing_source_documents(self, mock_context, mock_api_key): 339 | """Test correct_hallucinations with missing source documents""" 340 | result = await correct_hallucinations( 341 | generated_text="test text", 342 | documents=[], 343 | ctx=mock_context 344 | ) 345 | assert result == {"error": "Documents are required."} 346 | 347 | @pytest.mark.asyncio 348 | @patch.dict('os.environ', {}, clear=True) 349 | async def test_correct_hallucinations_missing_api_key(self, mock_context): 350 | """Test correct_hallucinations with missing API key""" 351 | result = await correct_hallucinations( 352 | generated_text="test text", 353 | documents=["doc1"], 354 | ctx=mock_context 355 | ) 356 | assert result == {"error": "API key not configured. Please use 'setup_vectara_api_key' tool first or set VECTARA_API_KEY environment variable."} 357 | 358 | @pytest.mark.asyncio 359 | @patch('vectara_mcp.server._make_api_request') 360 | async def test_correct_hallucinations_success(self, mock_api_request, mock_context, mock_api_key): 361 | """Test successful correct_hallucinations call""" 362 | mock_api_request.return_value = {"corrected_text": "Corrected version", "hallucinations": []} 363 | 364 | result = await correct_hallucinations( 365 | generated_text="test text with potential hallucination", 366 | documents=["Source document content"], 367 | ctx=mock_context 368 | ) 369 | 370 | expected_result = {"corrected_text": "Corrected version", "hallucinations": []} 371 | assert result == expected_result 372 | mock_context.info.assert_called_once() 373 | 374 | @pytest.mark.asyncio 375 | @patch('vectara_mcp.server._make_api_request') 376 | async def test_correct_hallucinations_403_error(self, mock_api_request, mock_context, mock_api_key): 377 | """Test correct_hallucinations with 403 permission error""" 378 | mock_api_request.side_effect = Exception("Permissions do not allow hallucination correction.") 379 | 380 | result = await correct_hallucinations( 381 | generated_text="test text", 382 | documents=["doc1"], 383 | ctx=mock_context 384 | ) 385 | 386 | assert result == {"error": "Error with hallucination correction: Permissions do not allow hallucination correction."} 387 | 388 | @pytest.mark.asyncio 389 | @patch('vectara_mcp.server._make_api_request') 390 | async def test_correct_hallucinations_400_error(self, mock_api_request, mock_context, mock_api_key): 391 | """Test correct_hallucinations with 400 bad request error""" 392 | mock_api_request.side_effect = Exception("Bad request: Invalid request format") 393 | 394 | result = await correct_hallucinations( 395 | generated_text="test text", 396 | documents=["doc1"], 397 | ctx=mock_context 398 | ) 399 | 400 | assert result == {"error": "Error with hallucination correction: Bad request: Invalid request format"} 401 | 402 | # EVAL_FACTUAL_CONSISTENCY TESTS 403 | @pytest.mark.asyncio 404 | async def test_eval_factual_consistency_missing_text(self, mock_context, mock_api_key): 405 | """Test eval_factual_consistency with missing text""" 406 | result = await eval_factual_consistency( 407 | generated_text="", 408 | documents=["doc1"], 409 | ctx=mock_context 410 | ) 411 | assert result == {"error": "Generated text is required."} 412 | 413 | @pytest.mark.asyncio 414 | async def test_eval_factual_consistency_missing_source_documents(self, mock_context, mock_api_key): 415 | """Test eval_factual_consistency with missing source documents""" 416 | result = await eval_factual_consistency( 417 | generated_text="test text", 418 | documents=[], 419 | ctx=mock_context 420 | ) 421 | assert result == {"error": "Documents are required."} 422 | 423 | @pytest.mark.asyncio 424 | @patch.dict('os.environ', {}, clear=True) 425 | async def test_eval_factual_consistency_missing_api_key(self, mock_context): 426 | """Test eval_factual_consistency with missing API key""" 427 | result = await eval_factual_consistency( 428 | generated_text="test text", 429 | documents=["doc1"], 430 | ctx=mock_context 431 | ) 432 | assert result == {"error": "API key not configured. Please use 'setup_vectara_api_key' tool first or set VECTARA_API_KEY environment variable."} 433 | 434 | @pytest.mark.asyncio 435 | @patch('vectara_mcp.server._make_api_request') 436 | async def test_eval_factual_consistency_success(self, mock_api_request, mock_context, mock_api_key): 437 | """Test successful eval_factual_consistency call""" 438 | mock_api_request.return_value = {"consistency_score": 0.85, "inconsistencies": []} 439 | 440 | result = await eval_factual_consistency( 441 | generated_text="test text for consistency check", 442 | documents=["Source document content"], 443 | ctx=mock_context 444 | ) 445 | 446 | expected_result = {"consistency_score": 0.85, "inconsistencies": []} 447 | assert result == expected_result 448 | mock_context.info.assert_called_once() 449 | 450 | @pytest.mark.asyncio 451 | @patch('vectara_mcp.server._make_api_request') 452 | async def test_eval_factual_consistency_422_error(self, mock_api_request, mock_context, mock_api_key): 453 | """Test eval_factual_consistency with 422 language not supported error""" 454 | mock_api_request.side_effect = Exception("Language not supported by service.") 455 | 456 | result = await eval_factual_consistency( 457 | generated_text="test text", 458 | documents=["doc1"], 459 | ctx=mock_context 460 | ) 461 | 462 | assert result == {"error": "Error with factual consistency evaluation: Language not supported by service."} 463 | 464 | @pytest.mark.asyncio 465 | @patch('vectara_mcp.server._make_api_request') 466 | async def test_eval_factual_consistency_exception(self, mock_api_request, mock_context, mock_api_key): 467 | """Test eval_factual_consistency with exception""" 468 | mock_api_request.side_effect = Exception("Network error") 469 | 470 | result = await eval_factual_consistency( 471 | generated_text="test text", 472 | documents=["doc1"], 473 | ctx=mock_context 474 | ) 475 | 476 | assert result == {"error": "Error with factual consistency evaluation: Network error"} 477 | 478 | @pytest.mark.asyncio 479 | @patch('vectara_mcp.server._make_api_request') 480 | async def test_correct_hallucinations_exception(self, mock_api_request, mock_context, mock_api_key): 481 | """Test correct_hallucinations with exception""" 482 | mock_api_request.side_effect = Exception("Network error") 483 | 484 | result = await correct_hallucinations( 485 | generated_text="test text", 486 | documents=["doc1"], 487 | ctx=mock_context 488 | ) 489 | 490 | assert result == {"error": "Error with hallucination correction: Network error"} -------------------------------------------------------------------------------- /vectara_mcp/server.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import atexit 3 | import asyncio 4 | import json 5 | import logging 6 | import os 7 | import signal 8 | import sys 9 | 10 | import aiohttp 11 | from mcp.server.fastmcp import FastMCP, Context 12 | from starlette.requests import Request 13 | from starlette.responses import JSONResponse 14 | 15 | from vectara_mcp._version import __version__ 16 | from vectara_mcp.auth import AuthMiddleware 17 | from vectara_mcp.connection_manager import ( 18 | get_connection_manager, cleanup_connections, connection_manager 19 | ) 20 | from vectara_mcp.health_checks import get_liveness, get_readiness, get_detailed_health 21 | 22 | logging.basicConfig(level=logging.INFO) 23 | logger = logging.getLogger(__name__) 24 | 25 | # Constants 26 | VECTARA_BASE_URL = "https://api.vectara.io/v2" 27 | VHC_MODEL_NAME = "vhc-large-1.0" 28 | DEFAULT_LANGUAGE = "en" 29 | API_KEY_ERROR_MESSAGE = ( 30 | "API key not configured. Please use 'setup_vectara_api_key' tool first " 31 | "or set VECTARA_API_KEY environment variable." 32 | ) 33 | 34 | # Create the Vectara MCP server with default settings 35 | # These will be overridden in main() by updating the settings 36 | mcp = FastMCP("vectara") 37 | 38 | # Initialize authentication component 39 | _auth_middleware = None # pylint: disable=invalid-name 40 | 41 | # Global API key storage (session-scoped) 42 | _stored_api_key: str | None = None 43 | # Global authentication requirement flag 44 | _auth_required: bool = True 45 | 46 | def initialize_auth(auth_required: bool): 47 | """Initialize authentication middleware. 48 | 49 | Args: 50 | auth_required: Whether authentication is required 51 | """ 52 | global _auth_middleware # pylint: disable=global-statement 53 | _auth_middleware = AuthMiddleware(auth_required=auth_required) 54 | 55 | def _mask_api_key(api_key: str) -> str: 56 | """Mask API key for safe logging/display. 57 | 58 | Args: 59 | api_key: The API key to mask 60 | 61 | Returns: 62 | str: Masked API key showing only first 4 and last 4 characters 63 | """ 64 | if not api_key or len(api_key) < 8: 65 | return "***" 66 | return f"{api_key[:4]}***{api_key[-4:]}" 67 | 68 | def _get_api_key() -> str | None: 69 | """Get API key with fallback priority: stored > environment > None. 70 | 71 | Returns: 72 | str: API key if available, None otherwise 73 | """ 74 | # Priority 1: Stored API key 75 | if _stored_api_key: 76 | return _stored_api_key 77 | 78 | # Priority 2: Environment variable; good for local deployments 79 | env_key = os.getenv("VECTARA_API_KEY") 80 | if env_key: 81 | return env_key 82 | 83 | # Priority 3: None (will trigger error in validation) 84 | return None 85 | 86 | def _validate_common_parameters(query: str = "", corpus_keys: list[str] = None) -> str | None: 87 | """Validate common parameters used across Vectara tools. 88 | 89 | Returns: 90 | str: Error message if validation fails, None if valid 91 | """ 92 | if not query: 93 | return "Query is required." 94 | if not corpus_keys: 95 | return "Corpus keys are required. Please ask the user to provide one or more corpus keys." 96 | 97 | # Check API key availability 98 | api_key = _get_api_key() 99 | if not api_key: 100 | return API_KEY_ERROR_MESSAGE 101 | 102 | return None 103 | 104 | 105 | def _validate_api_key(api_key_override: str = None) -> str: 106 | """Validate and return API key, raise exception if not found. 107 | 108 | Args: 109 | api_key_override: Optional API key override for testing 110 | 111 | Returns: 112 | str: Valid API key 113 | 114 | Raises: 115 | ValueError: If no API key is configured 116 | """ 117 | api_key = api_key_override or _get_api_key() 118 | if not api_key: 119 | raise ValueError( 120 | "API key not configured. Please use 'setup_vectara_api_key' tool first." 121 | ) 122 | return api_key 123 | 124 | def _build_headers(api_key: str) -> dict: 125 | """Build standard HTTP headers for Vectara API calls. 126 | 127 | Args: 128 | api_key: The API key to include in headers 129 | 130 | Returns: 131 | dict: Standard headers for Vectara API requests 132 | """ 133 | return { 134 | "x-api-key": api_key, 135 | "Content-Type": "application/json", 136 | "Accept": "application/json" 137 | } 138 | 139 | async def _handle_http_response( 140 | response: aiohttp.ClientResponse, error_context: str = "API" 141 | ) -> dict: 142 | """Handle HTTP response with unified error handling. 143 | 144 | Args: 145 | response: The aiohttp response object 146 | error_context: Context string for error messages 147 | 148 | Returns: 149 | dict: Response JSON data 150 | 151 | Raises: 152 | RuntimeError: With descriptive error message based on status code 153 | """ 154 | if response.status == 200: 155 | return await response.json() 156 | if response.status == 400: 157 | error_text = await response.text() 158 | raise RuntimeError(f"Bad request: {error_text}") 159 | if response.status == 403: 160 | if "hallucination" in error_context.lower(): 161 | raise PermissionError(f"Permissions do not allow {error_context}.") 162 | raise PermissionError("Permission denied. Check your API key and corpus access.") 163 | if response.status == 404: 164 | raise LookupError("Corpus not found. Check your corpus keys.") 165 | if response.status == 422: 166 | raise ValueError("Language not supported by service.") 167 | error_text = await response.text() 168 | raise RuntimeError(f"API error {response.status}: {error_text}") 169 | 170 | async def _make_api_request( 171 | url: str, 172 | payload: dict, 173 | ctx: Context = None, 174 | api_key_override: str = None, 175 | error_context: str = "API" 176 | ) -> dict: 177 | """Generic HTTP POST request with progress reporting and error handling. 178 | 179 | Uses persistent connection pooling and circuit breaker pattern. 180 | 181 | Args: 182 | url: The API endpoint URL 183 | payload: Request payload 184 | ctx: MCP context for progress reporting 185 | api_key_override: Optional API key override for testing 186 | error_context: Context for error messages 187 | 188 | Returns: 189 | dict: API response data 190 | 191 | Raises: 192 | Exception: With descriptive error message 193 | """ 194 | api_key = _validate_api_key(api_key_override) 195 | headers = _build_headers(api_key) 196 | 197 | # Get connection manager with persistent session 198 | conn_manager = await get_connection_manager() 199 | 200 | if ctx: 201 | await ctx.report_progress(0, 1) 202 | 203 | try: 204 | # Use persistent session with circuit breaker protection 205 | response = await conn_manager.request( 206 | method='POST', 207 | url=url, 208 | headers=headers, 209 | json_data=payload 210 | ) 211 | 212 | if ctx: 213 | await ctx.report_progress(1, 1) 214 | 215 | # Handle response using existing logic 216 | async with response: 217 | return await _handle_http_response(response, error_context) 218 | 219 | except Exception as e: # pylint: disable=broad-exception-caught 220 | # Log the error with context 221 | logger.error("API request failed: %s - %s", error_context, str(e)) 222 | raise 223 | 224 | 225 | # pylint: disable=too-many-arguments,too-many-positional-arguments 226 | def _build_query_payload( 227 | query: str, 228 | corpus_keys: list[str], 229 | n_sentences_before: int = 2, 230 | n_sentences_after: int = 2, 231 | lexical_interpolation: float = 0.005, 232 | max_used_search_results: int = 10, 233 | generation_preset_name: str = "vectara-summary-table-md-query-ext-jan-2025-gpt-4o", 234 | response_language: str = "eng", 235 | enable_generation: bool = True 236 | ) -> dict: 237 | """Build the query payload for Vectara API""" 238 | payload = { 239 | "query": query, 240 | "search": { 241 | "limit": 100, 242 | "corpora": [ 243 | { 244 | "corpus_key": corpus_key, 245 | "lexical_interpolation": lexical_interpolation 246 | } for corpus_key in corpus_keys 247 | ], 248 | "context_configuration": { 249 | "sentences_before": n_sentences_before, 250 | "sentences_after": n_sentences_after 251 | }, 252 | "reranker": { 253 | "type": "customer_reranker", 254 | "reranker_name": "Rerank_Multilingual_v1", 255 | "limit": 100, 256 | "cutoff": 0.2 257 | } 258 | }, 259 | "save_history": True, 260 | } 261 | 262 | if enable_generation: 263 | payload["generation"] = { 264 | "generation_preset_name": generation_preset_name, 265 | "max_used_search_results": max_used_search_results, 266 | "response_language": response_language, 267 | "citations": { 268 | "style": "markdown", 269 | "url_pattern": "{doc.url}", 270 | "text_pattern": "{doc.title}" 271 | }, 272 | "enable_factual_consistency_score": True 273 | } 274 | 275 | return payload 276 | 277 | async def _call_vectara_query( 278 | payload: dict, 279 | ctx: Context = None, 280 | api_key_override: str = None 281 | ) -> dict: 282 | """Make API call to Vectara query endpoint""" 283 | return await _make_api_request( 284 | f"{VECTARA_BASE_URL}/query", 285 | payload, 286 | ctx, 287 | api_key_override, 288 | "query" 289 | ) 290 | 291 | 292 | def _format_error(tool_name: str, error: Exception) -> str: 293 | """Format error messages consistently across tools. 294 | 295 | Args: 296 | tool_name: Name of the tool (e.g., "Vectara RAG query") 297 | error: The exception that occurred 298 | 299 | Returns: 300 | str: Formatted error message 301 | """ 302 | return f"Error with {tool_name}: {str(error)}" 303 | 304 | # API Key Management Tools 305 | @mcp.tool() 306 | async def setup_vectara_api_key( 307 | api_key: str, 308 | ctx: Context 309 | ) -> str: 310 | """ 311 | Configure and validate the Vectara API key for the session. 312 | 313 | Args: 314 | api_key: str, The Vectara API key to configure - required. 315 | 316 | Returns: 317 | str: Success message with masked API key or error message. 318 | """ 319 | global _stored_api_key # pylint: disable=global-statement 320 | 321 | if not api_key: 322 | return "API key is required." 323 | 324 | if ctx: 325 | ctx.info(f"Setting up Vectara API key: {_mask_api_key(api_key)}") 326 | 327 | try: 328 | # Test the API key with a minimal query to validate it 329 | test_payload = _build_query_payload( 330 | query="test", 331 | corpus_keys=["test"], # Will likely fail but tests API key auth 332 | enable_generation=False 333 | ) 334 | 335 | # Use our existing query function with the test API key 336 | await _call_vectara_query(test_payload, ctx, api_key_override=api_key) 337 | 338 | # If we get here without exception, API key is valid 339 | _stored_api_key = api_key 340 | masked_key = _mask_api_key(api_key) 341 | return f"API key configured successfully: {masked_key}" 342 | 343 | except Exception as e: # pylint: disable=broad-exception-caught 344 | error_msg = str(e) 345 | auth_errors = ["403", "401", "Permission denied", "API key error"] 346 | if any(err in error_msg for err in auth_errors): 347 | return "Invalid API key. Please check your Vectara API key and try again." 348 | request_errors = ["400", "404", "Bad request", "Corpus not found"] 349 | if any(status in error_msg for status in request_errors): 350 | # These errors indicate API key is valid but request failed for other reasons 351 | _stored_api_key = api_key 352 | masked_key = _mask_api_key(api_key) 353 | return f"API key configured successfully: {masked_key}" 354 | return f"API validation failed: {error_msg}" 355 | 356 | @mcp.tool() 357 | async def clear_vectara_api_key(ctx: Context) -> str: 358 | """ 359 | Clear the stored Vectara API key from server memory. 360 | 361 | Returns: 362 | str: Confirmation message. 363 | """ 364 | global _stored_api_key # pylint: disable=global-statement 365 | 366 | if ctx: 367 | ctx.info("Clearing stored Vectara API key") 368 | 369 | _stored_api_key = None 370 | return "API key cleared from server memory." 371 | 372 | 373 | # HTTP Health Check Endpoints (for Kubernetes/load balancers) 374 | # These are exposed as HTTP routes, not MCP tools 375 | 376 | 377 | @mcp.custom_route("/health", methods=["GET"]) 378 | async def http_health_check(request: Request) -> JSONResponse: # pylint: disable=unused-argument 379 | """Liveness probe - is the server running?""" 380 | try: 381 | result = await get_liveness() 382 | return JSONResponse(result) 383 | except Exception as e: # pylint: disable=broad-exception-caught 384 | return JSONResponse({"error": str(e)}, status_code=500) 385 | 386 | 387 | @mcp.custom_route("/ready", methods=["GET"]) 388 | async def http_readiness_check(request: Request) -> JSONResponse: # pylint: disable=unused-argument 389 | """Readiness probe - can the server handle traffic?""" 390 | try: 391 | result = await get_readiness() 392 | status_code = 200 if result.get("status") == "healthy" else 503 393 | return JSONResponse(result, status_code=status_code) 394 | except Exception as e: # pylint: disable=broad-exception-caught 395 | return JSONResponse({"error": str(e)}, status_code=500) 396 | 397 | 398 | @mcp.custom_route("/health/detailed", methods=["GET"]) 399 | async def http_detailed_health_check(request: Request) -> JSONResponse: # pylint: disable=unused-argument 400 | """Detailed health with metrics - for monitoring dashboards.""" 401 | try: 402 | result = await get_detailed_health() 403 | status_code = 200 if result.get("status") == "healthy" else 503 404 | return JSONResponse(result, status_code=status_code) 405 | except Exception as e: # pylint: disable=broad-exception-caught 406 | return JSONResponse({"error": str(e)}, status_code=500) 407 | 408 | 409 | @mcp.custom_route("/stats", methods=["GET"]) 410 | async def http_server_stats(request: Request) -> JSONResponse: # pylint: disable=unused-argument 411 | """Server statistics for monitoring.""" 412 | try: 413 | stats = { 414 | "connection_manager": connection_manager.get_stats(), 415 | "server_info": { 416 | "version": __version__, 417 | "auth_enabled": bool(_auth_required) 418 | } 419 | } 420 | return JSONResponse(stats) 421 | except Exception as e: # pylint: disable=broad-exception-caught 422 | return JSONResponse({"error": str(e)}, status_code=500) 423 | 424 | 425 | # Query tool 426 | # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals 427 | @mcp.tool() 428 | async def ask_vectara( 429 | query: str, 430 | ctx: Context, 431 | corpus_keys: list[str], 432 | n_sentences_before: int = 2, 433 | n_sentences_after: int = 2, 434 | lexical_interpolation: float = 0.005, 435 | max_used_search_results: int = 10, 436 | generation_preset_name: str = "vectara-summary-table-md-query-ext-jan-2025-gpt-4o", 437 | response_language: str = "eng", 438 | ) -> dict: 439 | """ 440 | Run a RAG query using Vectara, returning search results with generated response. 441 | 442 | Args: 443 | query: str, The user query to run - required. 444 | corpus_keys: list[str], List of Vectara corpus keys to use. Required. 445 | n_sentences_before: int, Sentences before answer for context. Default 2. 446 | n_sentences_after: int, Sentences after answer for context. Default 2. 447 | lexical_interpolation: float, Lexical interpolation amount. Default 0.005. 448 | max_used_search_results: int, Max search results to use. Default 10. 449 | generation_preset_name: str, Generation preset name. 450 | response_language: str, Response language. Default "eng". 451 | 452 | Note: API key must be configured first using 'setup_vectara_api_key' tool 453 | 454 | Returns: 455 | dict: Structured response containing: 456 | - "summary": Generated AI summary with markdown citations 457 | - "citations": List of citation objects with score, text, metadata 458 | - "factual_consistency_score": Score if available 459 | On error, returns dict with "error" key. 460 | """ 461 | # Validate parameters 462 | validation_error = _validate_common_parameters(query, corpus_keys) 463 | if validation_error: 464 | return {"error": validation_error} 465 | 466 | if ctx: 467 | ctx.info(f"Running Vectara RAG query: {query}") 468 | 469 | try: 470 | payload = _build_query_payload( 471 | query=query, 472 | corpus_keys=corpus_keys, 473 | n_sentences_before=n_sentences_before, 474 | n_sentences_after=n_sentences_after, 475 | lexical_interpolation=lexical_interpolation, 476 | max_used_search_results=max_used_search_results, 477 | generation_preset_name=generation_preset_name, 478 | response_language=response_language, 479 | enable_generation=True 480 | ) 481 | 482 | result = await _call_vectara_query(payload, ctx) 483 | 484 | # Extract the generated summary from the response 485 | summary_text = "" 486 | if "summary" in result: 487 | summary_text = result["summary"] 488 | elif "answer" in result: 489 | summary_text = result["answer"] 490 | else: 491 | return {"error": f"Unexpected response format: {json.dumps(result, indent=2)}"} 492 | 493 | # Build citations list 494 | citations = [] 495 | if "search_results" in result and result["search_results"]: 496 | for i, search_result in enumerate(result["search_results"], 1): 497 | citation = { 498 | "id": i, 499 | "score": search_result.get("score", 0.0), 500 | "text": search_result.get("text", ""), 501 | "document_metadata": search_result.get("document_metadata", {}) 502 | } 503 | citations.append(citation) 504 | 505 | # Build response dict 506 | response = { 507 | "summary": summary_text, 508 | "citations": citations 509 | } 510 | 511 | # Add factual consistency score if available 512 | if "factual_consistency_score" in result: 513 | response["factual_consistency_score"] = result["factual_consistency_score"] 514 | 515 | return response 516 | 517 | except Exception as e: # pylint: disable=broad-exception-caught 518 | return {"error": _format_error("Vectara RAG query", e)} 519 | 520 | 521 | # Query tool 522 | # pylint: disable=too-many-arguments,too-many-positional-arguments 523 | @mcp.tool() 524 | async def search_vectara( 525 | query: str, 526 | ctx: Context, 527 | corpus_keys: list[str], 528 | n_sentences_before: int = 2, 529 | n_sentences_after: int = 2, 530 | lexical_interpolation: float = 0.005 531 | ) -> dict: 532 | """ 533 | Run a semantic search query using Vectara, without generation. 534 | 535 | Args: 536 | query: str, The user query to run - required. 537 | corpus_keys: list[str], List of Vectara corpus keys to use. Required. 538 | n_sentences_before: int, Sentences before answer for context. Default 2. 539 | n_sentences_after: int, Sentences after answer for context. Default 2. 540 | lexical_interpolation: float, Lexical interpolation amount. Default 0.005. 541 | 542 | Note: API key must be configured first using 'setup_vectara_api_key' tool 543 | 544 | Returns: 545 | dict: Raw search results from Vectara API containing: 546 | - "search_results": List of result objects with scores, text, metadata 547 | - Additional response metadata from the API 548 | On error, returns dict with "error" key. 549 | """ 550 | # Validate parameters 551 | validation_error = _validate_common_parameters(query, corpus_keys) 552 | if validation_error: 553 | return {"error": validation_error} 554 | 555 | if ctx: 556 | ctx.info(f"Running Vectara semantic search query: {query}") 557 | 558 | try: 559 | payload = _build_query_payload( 560 | query=query, 561 | corpus_keys=corpus_keys, 562 | n_sentences_before=n_sentences_before, 563 | n_sentences_after=n_sentences_after, 564 | lexical_interpolation=lexical_interpolation, 565 | enable_generation=False 566 | ) 567 | 568 | result = await _call_vectara_query(payload, ctx) 569 | return result 570 | 571 | except Exception as e: # pylint: disable=broad-exception-caught 572 | return {"error": _format_error("Vectara semantic search query", e)} 573 | 574 | 575 | @mcp.tool() 576 | async def correct_hallucinations( 577 | generated_text: str, 578 | documents: list[str], 579 | ctx: Context, 580 | query: str = "", 581 | ) -> dict: 582 | """ 583 | Identify and correct hallucinations in generated text using Vectara API. 584 | 585 | Args: 586 | generated_text: str, The text to analyze for hallucinations - required. 587 | documents: list[str], Source documents to compare against - required. 588 | query: str, The original user query - optional. 589 | 590 | Note: API key must be configured first using 'setup_vectara_api_key' tool 591 | 592 | Returns: 593 | dict: Structured response containing: 594 | - "corrected_text": Text with hallucinations corrected 595 | - "corrections": Array of correction objects with: 596 | * "original_text": The hallucinated content 597 | * "corrected_text": The factually accurate replacement 598 | * "explanation": Detailed reason for the correction 599 | On error, returns dict with "error" key. 600 | """ 601 | # Validate parameters 602 | if not generated_text: 603 | return {"error": "Generated text is required."} 604 | if not documents: 605 | return {"error": "Documents are required."} 606 | 607 | # Validate API key early 608 | api_key = _get_api_key() 609 | if not api_key: 610 | return {"error": API_KEY_ERROR_MESSAGE} 611 | 612 | if ctx: 613 | ctx.info(f"Analyzing text for hallucinations: {generated_text[:100]}...") 614 | 615 | try: 616 | # Build payload for VHC hallucination correction endpoint 617 | payload = { 618 | "generated_text": generated_text, 619 | "documents": [{"text": doc} for doc in documents], 620 | "model_name": VHC_MODEL_NAME 621 | } 622 | if query: 623 | payload["query"] = query 624 | 625 | return await _make_api_request( 626 | f"{VECTARA_BASE_URL}/hallucination_correctors/correct_hallucinations", 627 | payload, 628 | ctx, 629 | None, 630 | "hallucination correction" 631 | ) 632 | 633 | except Exception as e: # pylint: disable=broad-exception-caught 634 | return {"error": _format_error("hallucination correction", e)} 635 | 636 | 637 | @mcp.tool() 638 | async def eval_factual_consistency( 639 | generated_text: str, 640 | documents: list[str], 641 | ctx: Context, 642 | ) -> dict: 643 | """ 644 | Evaluate factual consistency of text against source documents using Vectara. 645 | 646 | Args: 647 | generated_text: str, The text to evaluate for factual consistency. 648 | documents: list[str], Source documents to compare against - required. 649 | 650 | Note: API key must be configured first using 'setup_vectara_api_key' tool 651 | 652 | Returns: 653 | dict: Response containing factual consistency evaluation score. 654 | On error, returns dict with "error" key. 655 | """ 656 | # Validate parameters 657 | if not generated_text: 658 | return {"error": "Generated text is required."} 659 | if not documents: 660 | return {"error": "Documents are required."} 661 | 662 | # Validate API key early 663 | api_key = _get_api_key() 664 | if not api_key: 665 | return {"error": API_KEY_ERROR_MESSAGE} 666 | 667 | if ctx: 668 | ctx.info(f"Evaluating factual consistency for text: {generated_text[:100]}...") 669 | 670 | try: 671 | # Build payload for dedicated factual consistency evaluation endpoint 672 | payload = { 673 | "generated_text": generated_text, 674 | "source_texts": documents, 675 | } 676 | 677 | return await _make_api_request( 678 | f"{VECTARA_BASE_URL}/evaluate_factual_consistency", 679 | payload, 680 | ctx, 681 | None, 682 | "factual consistency evaluation" 683 | ) 684 | 685 | except Exception as e: # pylint: disable=broad-exception-caught 686 | return {"error": _format_error("factual consistency evaluation", e)} 687 | 688 | 689 | def _setup_signal_handlers(): 690 | """Setup signal handlers for graceful shutdown.""" 691 | def signal_handler(signum, _frame): 692 | logger.info("Received signal %s, initiating graceful shutdown...", signum) 693 | # Schedule cleanup in the event loop 694 | if hasattr(asyncio, 'get_running_loop'): 695 | try: 696 | loop = asyncio.get_running_loop() 697 | loop.create_task(cleanup_connections()) 698 | except RuntimeError: 699 | # No running loop, cleanup will happen at exit 700 | pass 701 | sys.exit(0) 702 | 703 | signal.signal(signal.SIGINT, signal_handler) 704 | signal.signal(signal.SIGTERM, signal_handler) 705 | 706 | 707 | def _setup_cleanup(): 708 | """Setup cleanup for process exit.""" 709 | atexit.register(lambda: asyncio.run(cleanup_connections())) 710 | 711 | 712 | def main(): 713 | """Command-line interface for starting the Vectara MCP Server.""" 714 | parser = argparse.ArgumentParser(description="Vectara MCP Server") 715 | parser.add_argument( 716 | '--transport', 717 | default='sse', 718 | choices=['stdio', 'sse', 'streamable-http'], 719 | help='Transport protocol: stdio, sse (default), or streamable-http' 720 | ) 721 | parser.add_argument( 722 | '--host', 723 | default='127.0.0.1', 724 | help='Host address for network transports (default: 127.0.0.1)' 725 | ) 726 | parser.add_argument( 727 | '--port', 728 | type=int, 729 | default=8000, 730 | help='Port for network transports (default: 8000)' 731 | ) 732 | parser.add_argument( 733 | '--no-auth', 734 | action='store_true', 735 | help='Disable authentication (DANGEROUS: development only)' 736 | ) 737 | parser.add_argument( 738 | '--path', 739 | default='/sse/messages', 740 | help='Path for SSE endpoint (default: /sse/messages)' 741 | ) 742 | 743 | args = parser.parse_args() 744 | 745 | # Configure authentication based on transport and flags 746 | auth_enabled = args.transport != 'stdio' and not args.no_auth 747 | 748 | # Update MCP server settings with runtime configuration 749 | if args.transport != 'stdio': 750 | mcp.settings.host = args.host 751 | mcp.settings.port = args.port 752 | mcp.settings.sse_path = args.path 753 | 754 | # Display startup information 755 | if args.transport == 'stdio': 756 | logger.warning("STDIO transport is less secure. Use only for local dev.") 757 | logger.info("Starting Vectara MCP Server (STDIO mode)...") 758 | mcp.run() 759 | sys.exit(0) 760 | else: 761 | if args.no_auth: 762 | logger.warning("Authentication disabled. NEVER use in production!") 763 | 764 | transport_name = "Streamable HTTP" if args.transport == 'streamable-http' else "SSE" 765 | auth_status = "enabled" if auth_enabled else "DISABLED" 766 | path_suffix = args.path if args.transport == 'sse' else '/mcp' 767 | 768 | logger.info("Starting Vectara MCP Server (%s mode)", transport_name) 769 | logger.info("Server: http://%s:%s%s", args.host, args.port, path_suffix) 770 | logger.info("Authentication: %s", auth_status) 771 | 772 | # Initialize authentication middleware 773 | initialize_auth(auth_enabled) 774 | 775 | # Setup signal handlers and cleanup 776 | _setup_signal_handlers() 777 | _setup_cleanup() 778 | 779 | if args.transport == 'sse': 780 | mcp.run(transport='sse', mount_path=args.path) 781 | else: # streamable-http 782 | mcp.run(transport='streamable-http') 783 | 784 | sys.exit(0) 785 | 786 | if __name__ == "__main__": 787 | main() 788 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.11" 4 | 5 | [[package]] 6 | name = "annotated-types" 7 | version = "0.7.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.9.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "idna" }, 20 | { name = "sniffio" }, 21 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 22 | ] 23 | sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } 24 | wheels = [ 25 | { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, 26 | ] 27 | 28 | [[package]] 29 | name = "certifi" 30 | version = "2025.1.31" 31 | source = { registry = "https://pypi.org/simple" } 32 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 33 | wheels = [ 34 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 35 | ] 36 | 37 | [[package]] 38 | name = "click" 39 | version = "8.1.8" 40 | source = { registry = "https://pypi.org/simple" } 41 | dependencies = [ 42 | { name = "colorama", marker = "sys_platform == 'win32'" }, 43 | ] 44 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 45 | wheels = [ 46 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 47 | ] 48 | 49 | [[package]] 50 | name = "colorama" 51 | version = "0.4.6" 52 | source = { registry = "https://pypi.org/simple" } 53 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 54 | wheels = [ 55 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 56 | ] 57 | 58 | [[package]] 59 | name = "h11" 60 | version = "0.14.0" 61 | source = { registry = "https://pypi.org/simple" } 62 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 63 | wheels = [ 64 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 65 | ] 66 | 67 | [[package]] 68 | name = "httpcore" 69 | version = "1.0.7" 70 | source = { registry = "https://pypi.org/simple" } 71 | dependencies = [ 72 | { name = "certifi" }, 73 | { name = "h11" }, 74 | ] 75 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 76 | wheels = [ 77 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 78 | ] 79 | 80 | [[package]] 81 | name = "httpx" 82 | version = "0.28.1" 83 | source = { registry = "https://pypi.org/simple" } 84 | dependencies = [ 85 | { name = "anyio" }, 86 | { name = "certifi" }, 87 | { name = "httpcore" }, 88 | { name = "idna" }, 89 | ] 90 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 91 | wheels = [ 92 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 93 | ] 94 | 95 | [[package]] 96 | name = "httpx-sse" 97 | version = "0.4.0" 98 | source = { registry = "https://pypi.org/simple" } 99 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 100 | wheels = [ 101 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 102 | ] 103 | 104 | [[package]] 105 | name = "idna" 106 | version = "3.10" 107 | source = { registry = "https://pypi.org/simple" } 108 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 109 | wheels = [ 110 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 111 | ] 112 | 113 | [[package]] 114 | name = "mcp" 115 | version = "1.6.0" 116 | source = { registry = "https://pypi.org/simple" } 117 | dependencies = [ 118 | { name = "anyio" }, 119 | { name = "httpx" }, 120 | { name = "httpx-sse" }, 121 | { name = "pydantic" }, 122 | { name = "pydantic-settings" }, 123 | { name = "sse-starlette" }, 124 | { name = "starlette" }, 125 | { name = "uvicorn" }, 126 | ] 127 | sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } 128 | wheels = [ 129 | { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, 130 | ] 131 | 132 | [[package]] 133 | name = "pydantic" 134 | version = "2.11.2" 135 | source = { registry = "https://pypi.org/simple" } 136 | dependencies = [ 137 | { name = "annotated-types" }, 138 | { name = "pydantic-core" }, 139 | { name = "typing-extensions" }, 140 | { name = "typing-inspection" }, 141 | ] 142 | sdist = { url = "https://files.pythonhosted.org/packages/b0/41/832125a41fe098b58d1fdd04ae819b4dc6b34d6b09ed78304fd93d4bc051/pydantic-2.11.2.tar.gz", hash = "sha256:2138628e050bd7a1e70b91d4bf4a91167f4ad76fdb83209b107c8d84b854917e", size = 784742 } 143 | wheels = [ 144 | { url = "https://files.pythonhosted.org/packages/bf/c2/0f3baea344d0b15e35cb3e04ad5b953fa05106b76efbf4c782a3f47f22f5/pydantic-2.11.2-py3-none-any.whl", hash = "sha256:7f17d25846bcdf89b670a86cdfe7b29a9f1c9ca23dee154221c9aa81845cfca7", size = 443295 }, 145 | ] 146 | 147 | [[package]] 148 | name = "pydantic-core" 149 | version = "2.33.1" 150 | source = { registry = "https://pypi.org/simple" } 151 | dependencies = [ 152 | { name = "typing-extensions" }, 153 | ] 154 | sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } 155 | wheels = [ 156 | { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224 }, 157 | { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845 }, 158 | { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029 }, 159 | { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784 }, 160 | { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075 }, 161 | { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849 }, 162 | { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794 }, 163 | { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237 }, 164 | { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351 }, 165 | { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914 }, 166 | { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385 }, 167 | { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765 }, 168 | { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688 }, 169 | { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185 }, 170 | { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 }, 171 | { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 }, 172 | { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 }, 173 | { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 }, 174 | { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 }, 175 | { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 }, 176 | { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 }, 177 | { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 }, 178 | { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 }, 179 | { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 }, 180 | { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 }, 181 | { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 }, 182 | { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 }, 183 | { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 }, 184 | { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 }, 185 | { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 }, 186 | { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 }, 187 | { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 }, 188 | { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 }, 189 | { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 }, 190 | { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 }, 191 | { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 }, 192 | { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 }, 193 | { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 }, 194 | { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 }, 195 | { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 }, 196 | { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 }, 197 | { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 }, 198 | { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 }, 199 | { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 }, 200 | { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 }, 201 | { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858 }, 202 | { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745 }, 203 | { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188 }, 204 | { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479 }, 205 | { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415 }, 206 | { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623 }, 207 | { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175 }, 208 | { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674 }, 209 | { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 }, 210 | ] 211 | 212 | [[package]] 213 | name = "pydantic-settings" 214 | version = "2.8.1" 215 | source = { registry = "https://pypi.org/simple" } 216 | dependencies = [ 217 | { name = "pydantic" }, 218 | { name = "python-dotenv" }, 219 | ] 220 | sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } 221 | wheels = [ 222 | { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, 223 | ] 224 | 225 | [[package]] 226 | name = "python-dotenv" 227 | version = "1.1.0" 228 | source = { registry = "https://pypi.org/simple" } 229 | sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } 230 | wheels = [ 231 | { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, 232 | ] 233 | 234 | [[package]] 235 | name = "pyyaml" 236 | version = "6.0.2" 237 | source = { registry = "https://pypi.org/simple" } 238 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 239 | wheels = [ 240 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, 241 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, 242 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, 243 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, 244 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, 245 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, 246 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, 247 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, 248 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, 249 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 250 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 251 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 252 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 253 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 254 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 255 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 256 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 257 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 258 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 259 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 260 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 261 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 262 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 263 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 264 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 265 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 266 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 267 | ] 268 | 269 | [[package]] 270 | name = "sniffio" 271 | version = "1.3.1" 272 | source = { registry = "https://pypi.org/simple" } 273 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 274 | wheels = [ 275 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 276 | ] 277 | 278 | [[package]] 279 | name = "sse-starlette" 280 | version = "2.2.1" 281 | source = { registry = "https://pypi.org/simple" } 282 | dependencies = [ 283 | { name = "anyio" }, 284 | { name = "starlette" }, 285 | ] 286 | sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } 287 | wheels = [ 288 | { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, 289 | ] 290 | 291 | [[package]] 292 | name = "starlette" 293 | version = "0.46.1" 294 | source = { registry = "https://pypi.org/simple" } 295 | dependencies = [ 296 | { name = "anyio" }, 297 | ] 298 | sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } 299 | wheels = [ 300 | { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, 301 | ] 302 | 303 | [[package]] 304 | name = "typing-extensions" 305 | version = "4.13.1" 306 | source = { registry = "https://pypi.org/simple" } 307 | sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } 308 | wheels = [ 309 | { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, 310 | ] 311 | 312 | [[package]] 313 | name = "typing-inspection" 314 | version = "0.4.0" 315 | source = { registry = "https://pypi.org/simple" } 316 | dependencies = [ 317 | { name = "typing-extensions" }, 318 | ] 319 | sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } 320 | wheels = [ 321 | { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, 322 | ] 323 | 324 | [[package]] 325 | name = "uvicorn" 326 | version = "0.34.0" 327 | source = { registry = "https://pypi.org/simple" } 328 | dependencies = [ 329 | { name = "click" }, 330 | { name = "h11" }, 331 | ] 332 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 333 | wheels = [ 334 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 335 | ] 336 | 337 | [[package]] 338 | name = "vectara" 339 | version = "0.2.44" 340 | source = { registry = "https://pypi.org/simple" } 341 | dependencies = [ 342 | { name = "httpx" }, 343 | { name = "httpx-sse" }, 344 | { name = "pydantic" }, 345 | { name = "pydantic-core" }, 346 | { name = "pyyaml" }, 347 | { name = "typing-extensions" }, 348 | ] 349 | sdist = { url = "https://files.pythonhosted.org/packages/86/00/89abbe94d443480d7a3fafe39163caf50a317bebc1bceda1594e43ab21a8/vectara-0.2.44.tar.gz", hash = "sha256:b254088c3bc9c59ba64072f8958d55e9cd944839119741bf437fbb28e6170aec", size = 97848 } 350 | wheels = [ 351 | { url = "https://files.pythonhosted.org/packages/fb/1f/16c53902616eddd500e5530f558ed4487a67edfe086e0cc9b8119b227643/vectara-0.2.44-py3-none-any.whl", hash = "sha256:fba307f545a4c446bc2b7e7e5332284e8e37a229315b0fc2df61b73f93009a92", size = 178437 }, 352 | ] 353 | 354 | [[package]] 355 | name = "vectara-mcp" 356 | version = "0.1.6" 357 | source = { editable = "." } 358 | dependencies = [ 359 | { name = "mcp" }, 360 | { name = "uvicorn" }, 361 | { name = "vectara" }, 362 | ] 363 | 364 | [package.metadata] 365 | requires-dist = [ 366 | { name = "mcp", specifier = ">=1.6.0" }, 367 | { name = "uvicorn", specifier = ">=0.34.0" }, 368 | { name = "vectara", specifier = ">=0.2.44" }, 369 | ] 370 | --------------------------------------------------------------------------------