├── requirements.txt ├── pytest.ini ├── src └── mssql_mcp_server │ ├── __main__.py │ ├── __init__.py │ └── server.py ├── .dockerignore ├── requirements-dev.txt ├── Dockerfile ├── docker-compose.example.yml ├── .gitignore ├── LICENSE ├── SECURITY.md ├── docker-compose.yml ├── pyproject.toml ├── test_connection.py ├── tests ├── conftest.py ├── test_server.py ├── test_config.py ├── test_security.py ├── test_integration.py ├── test_error_handling.py └── test_performance.py ├── .github └── workflows │ ├── security.yml │ ├── release.yml │ ├── publish.yml │ └── ci.yml ├── Makefile ├── README.md ├── close_fixed_issues.sh ├── run_tests.py └── uv.lock /requirements.txt: -------------------------------------------------------------------------------- 1 | mcp>=1.0.0 2 | pymssql>=2.2.7 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | asyncio_default_fixture_loop_scope = function 4 | testpaths = tests 5 | python_files = test_*.py -------------------------------------------------------------------------------- /src/mssql_mcp_server/__main__.py: -------------------------------------------------------------------------------- 1 | """Entry point for running the module with python -m mssql_mcp_server.""" 2 | from . import main 3 | 4 | if __name__ == "__main__": 5 | main() -------------------------------------------------------------------------------- /src/mssql_mcp_server/__init__.py: -------------------------------------------------------------------------------- 1 | from . import server 2 | import asyncio 3 | 4 | def main(): 5 | """Main entry point for the package.""" 6 | asyncio.run(server.main()) 7 | 8 | # Expose important items at package level 9 | __all__ = ['main', 'server'] -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | 5 | # Virtual Environment 6 | venv/ 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | *.so 11 | .Python 12 | .pytest_cache/ 13 | .coverage 14 | htmlcov/ 15 | 16 | # Logs 17 | *.log 18 | 19 | # Docker 20 | Dockerfile 21 | .dockerignore 22 | docker-compose.yml 23 | 24 | # Editor directories and files 25 | .idea/ 26 | .vscode/ 27 | *.swp 28 | *.swo -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Testing 2 | pytest>=7.0.0 3 | pytest-asyncio>=0.23.0 4 | pytest-cov>=4.1.0 5 | pytest-timeout>=2.1.0 6 | pytest-xdist>=3.3.0 7 | pytest-mock>=3.11.0 8 | 9 | # Code Quality 10 | black>=23.0.0 11 | isort>=5.12.0 12 | mypy>=1.0.0 13 | ruff>=0.0.280 14 | 15 | # Security 16 | safety>=2.3.0 17 | bandit>=1.7.0 18 | pip-audit>=2.6.0 19 | 20 | # Performance Testing 21 | psutil>=5.9.0 22 | 23 | # Build and Release 24 | build>=1.0.0 25 | twine>=4.0.0 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install system dependencies for pymssql 6 | RUN apt-get update && apt-get install -y \ 7 | freetds-dev \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | # Copy requirements 11 | COPY requirements.txt . 12 | 13 | # Install Python dependencies 14 | RUN pip install --no-cache-dir -r requirements.txt 15 | 16 | # Copy project files 17 | COPY . . 18 | 19 | # Run the MCP server 20 | CMD ["python", "-m", "mssql_mcp_server"] -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mssql-mcp-server: 5 | build: . 6 | environment: 7 | - MSSQL_SERVER=sqlserver # Use service name for internal Docker network 8 | - MSSQL_DATABASE=TestDB 9 | - MSSQL_USER=sa 10 | - MSSQL_PASSWORD=YourStrong@Passw0rd 11 | - MSSQL_PORT=1433 12 | - MSSQL_ENCRYPT=false 13 | depends_on: 14 | - sqlserver 15 | stdin_open: true 16 | tty: true 17 | 18 | # Example SQL Server for testing 19 | sqlserver: 20 | image: mcr.microsoft.com/mssql/server:2022-latest 21 | environment: 22 | - ACCEPT_EULA=Y 23 | - SA_PASSWORD=YourStrong@Passw0rd 24 | - MSSQL_PID=Developer 25 | ports: 26 | - "1433:1433" 27 | volumes: 28 | - sqlserver-data:/var/opt/mssql 29 | 30 | volumes: 31 | sqlserver-data: -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | *.manifest 30 | *.spec 31 | 32 | # Unit test / coverage reports 33 | htmlcov/ 34 | .tox/ 35 | .coverage 36 | .coverage.* 37 | .cache 38 | nosetests.xml 39 | coverage.xml 40 | *.cover 41 | .hypothesis/ 42 | .pytest_cache/ 43 | 44 | # Virtual environments 45 | venv/ 46 | ENV/ 47 | env/ 48 | .venv 49 | 50 | # IDEs 51 | .vscode/ 52 | .idea/ 53 | *.swp 54 | *.swo 55 | *~ 56 | 57 | # OS 58 | .DS_Store 59 | .DS_Store? 60 | ._* 61 | .Spotlight-V100 62 | .Trashes 63 | Thumbs.db 64 | ehthumbs.db 65 | 66 | # Environment variables 67 | .env 68 | *.env 69 | !.env.example 70 | 71 | # Logs 72 | *.log 73 | 74 | # Database 75 | *.db 76 | *.sqlite 77 | 78 | # MCP specific 79 | mcp-workspace/ 80 | 81 | # Claude local settings 82 | .claude/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dana K. Williams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting Security Issues 4 | 5 | If you discover a security vulnerability, please email security@example.com instead of using the public issue tracker. 6 | 7 | ## Security Best Practices 8 | 9 | When using this MCP server: 10 | 11 | 1. **Database User**: Create a dedicated SQL user with minimal permissions 12 | 2. **Never use sa/admin accounts** in production 13 | 3. **Use Windows Authentication** when possible 14 | 4. **Enable encryption** for sensitive data: `MSSQL_ENCRYPT=true` 15 | 5. **Restrict permissions** to only necessary tables and operations 16 | 17 | ## SQL Injection Protection 18 | 19 | This server includes built-in protection against SQL injection: 20 | - Table names are validated with strict regex patterns 21 | - All identifiers are properly escaped 22 | - User input is parameterized where possible 23 | 24 | ## Example: Minimal Permissions 25 | 26 | ```sql 27 | -- Create a restricted user 28 | CREATE LOGIN mcp_user WITH PASSWORD = 'StrongPassword123!'; 29 | CREATE USER mcp_user FOR LOGIN mcp_user; 30 | 31 | -- Grant only necessary permissions 32 | GRANT SELECT ON Schema.TableName TO mcp_user; 33 | GRANT INSERT, UPDATE ON Schema.AuditLog TO mcp_user; 34 | ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | # SQL Server 5 | mssql: 6 | image: mcr.microsoft.com/mssql/server:2019-latest 7 | platform: linux/amd64 8 | environment: 9 | - ACCEPT_EULA=Y 10 | - MSSQL_SA_PASSWORD=${MSSQL_PASSWORD:-StrongPassword123!} 11 | ports: 12 | - "${HOST_SQL_PORT:-1434}:1433" 13 | healthcheck: 14 | test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "${MSSQL_PASSWORD:-StrongPassword123!}" -Q "SELECT 1" || exit 1 15 | interval: 10s 16 | timeout: 3s 17 | retries: 10 18 | start_period: 10s 19 | volumes: 20 | - mssql_data:/var/opt/mssql 21 | mem_limit: ${SQL_MEMORY_LIMIT:-2g} 22 | 23 | # MCP Server 24 | mcp_server: 25 | build: 26 | context: . 27 | dockerfile: Dockerfile 28 | depends_on: 29 | mssql: 30 | condition: service_healthy 31 | environment: 32 | - MSSQL_SERVER=${MSSQL_SERVER:-mssql} 33 | - MSSQL_PORT=${MSSQL_PORT:-1433} 34 | - MSSQL_USER=${MSSQL_USER:-sa} 35 | - MSSQL_PASSWORD=${MSSQL_PASSWORD:-StrongPassword123!} 36 | - MSSQL_DATABASE=${MSSQL_DATABASE:-master} 37 | volumes: 38 | - .:/app 39 | 40 | volumes: 41 | mssql_data: 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "microsoft_sql_server_mcp" 3 | version = "0.1.0" 4 | description = "A Model Context Protocol (MCP) server that enables secure interaction with Microsoft SQL Server databases." 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | authors = [ 8 | {name = "Richard Han", email = "noreply@example.com"} 9 | ] 10 | license = {text = "MIT"} 11 | keywords = ["mcp", "mssql", "sql-server", "database", "ai"] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | ] 20 | dependencies = [ 21 | "mcp>=1.0.0", 22 | "pymssql>=2.2.8", 23 | ] 24 | 25 | [tool.mcp] 26 | system_dependencies.darwin = ["freetds"] 27 | system_dependencies.linux = ["freetds-dev"] 28 | system_dependencies.win32 = [] 29 | 30 | [build-system] 31 | requires = ["hatchling"] 32 | build-backend = "hatchling.build" 33 | 34 | [project.scripts] 35 | mssql_mcp_server = "mssql_mcp_server:main" 36 | 37 | [tool.hatch.build.targets.wheel] 38 | packages = ["src/mssql_mcp_server"] 39 | -------------------------------------------------------------------------------- /test_connection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Test SQL Server connection using the same configuration as the MCP server.""" 3 | 4 | import os 5 | import sys 6 | import pymssql 7 | 8 | # Add src to path to import our server module 9 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) 10 | 11 | from mssql_mcp_server.server import get_db_config 12 | 13 | try: 14 | print("Loading database configuration from environment variables...") 15 | config = get_db_config() 16 | 17 | # Mask sensitive information for display 18 | display_config = config.copy() 19 | if 'password' in display_config: 20 | display_config['password'] = '***' 21 | print(f"Configuration: {display_config}") 22 | 23 | print("\nAttempting to connect to SQL Server...") 24 | conn = pymssql.connect(**config) 25 | cursor = conn.cursor() 26 | print("Connection successful!") 27 | 28 | print("\nTesting query execution...") 29 | cursor.execute("SELECT TOP 5 TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'") 30 | rows = cursor.fetchall() 31 | print(f"Found {len(rows)} tables:") 32 | for row in rows: 33 | print(f" - {row[0]}") 34 | 35 | cursor.close() 36 | conn.close() 37 | print("\nConnection test completed successfully!") 38 | except Exception as e: 39 | print(f"Error: {str(e)}") 40 | import traceback 41 | traceback.print_exc() 42 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # tests/conftest.py 2 | import pytest 3 | import os 4 | import pymssql 5 | 6 | @pytest.fixture(scope="session") 7 | def mssql_connection(): 8 | """Create a test database connection.""" 9 | try: 10 | connection = pymssql.connect( 11 | server=os.getenv("MSSQL_SERVER", "localhost"), 12 | user=os.getenv("MSSQL_USER", "sa"), 13 | password=os.getenv("MSSQL_PASSWORD", "testpassword"), 14 | database=os.getenv("MSSQL_DATABASE", "test_db") 15 | ) 16 | 17 | # Create a test table 18 | cursor = connection.cursor() 19 | cursor.execute(""" 20 | IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'test_table') 21 | CREATE TABLE test_table ( 22 | id INT IDENTITY(1,1) PRIMARY KEY, 23 | name VARCHAR(255), 24 | value INT 25 | ) 26 | """) 27 | connection.commit() 28 | 29 | yield connection 30 | 31 | # Cleanup 32 | cursor.execute("DROP TABLE IF EXISTS test_table") 33 | connection.commit() 34 | cursor.close() 35 | connection.close() 36 | 37 | except pymssql.Error as e: 38 | pytest.fail(f"Failed to connect to SQL Server: {e}") 39 | 40 | @pytest.fixture(scope="session") 41 | def mssql_cursor(mssql_connection): 42 | """Create a test cursor.""" 43 | cursor = mssql_connection.cursor() 44 | yield cursor 45 | cursor.close() -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mssql_mcp_server.server import app, list_tools, list_resources, read_resource, call_tool 3 | from pydantic import AnyUrl 4 | 5 | def test_server_initialization(): 6 | """Test that the server initializes correctly.""" 7 | assert app.name == "mssql_mcp_server" 8 | 9 | @pytest.mark.asyncio 10 | async def test_list_tools(): 11 | """Test that list_tools returns expected tools.""" 12 | tools = await list_tools() 13 | assert len(tools) == 1 14 | assert tools[0].name == "execute_sql" 15 | assert "query" in tools[0].inputSchema["properties"] 16 | 17 | @pytest.mark.asyncio 18 | async def test_call_tool_invalid_name(): 19 | """Test calling a tool with an invalid name.""" 20 | with pytest.raises(ValueError, match="Unknown tool"): 21 | await call_tool("invalid_tool", {}) 22 | 23 | @pytest.mark.asyncio 24 | async def test_call_tool_missing_query(): 25 | """Test calling execute_sql without a query.""" 26 | with pytest.raises(ValueError, match="Query is required"): 27 | await call_tool("execute_sql", {}) 28 | 29 | # Skip database-dependent tests if no database connection 30 | @pytest.mark.asyncio 31 | @pytest.mark.skipif( 32 | not all([ 33 | pytest.importorskip("pymssql"), 34 | pytest.importorskip("mssql_mcp_server") 35 | ]), 36 | reason="SQL Server connection not available" 37 | ) 38 | async def test_list_resources(): 39 | """Test listing resources (requires database connection).""" 40 | try: 41 | resources = await list_resources() 42 | assert isinstance(resources, list) 43 | except ValueError as e: 44 | if "Missing required database configuration" in str(e): 45 | pytest.skip("Database configuration not available") 46 | raise 47 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security Scan 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 1' # Weekly on Monday 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | security-events: write 15 | 16 | jobs: 17 | dependency-check: 18 | name: Dependency Security Check 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: '3.11' 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install safety pip-audit 33 | pip install -r requirements.txt 34 | 35 | - name: Run Safety check 36 | run: | 37 | safety check --json --output safety-report.json || true 38 | 39 | - name: Run pip-audit 40 | run: | 41 | pip-audit --format json --output pip-audit-report.json || true 42 | 43 | - name: Upload security reports 44 | uses: actions/upload-artifact@v4 45 | if: always() 46 | with: 47 | name: dependency-security-reports 48 | path: | 49 | safety-report.json 50 | pip-audit-report.json 51 | 52 | codeql-analysis: 53 | name: CodeQL Analysis 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - name: Initialize CodeQL 60 | uses: github/codeql-action/init@v3 61 | with: 62 | languages: python 63 | 64 | - name: Autobuild 65 | uses: github/codeql-action/autobuild@v3 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v3 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: venv install install-dev test lint format clean run docker-build docker-up docker-down docker-test docker-exec 2 | 3 | PYTHON := python3 4 | VENV := venv 5 | BIN := $(VENV)/bin 6 | 7 | venv: 8 | $(PYTHON) -m venv $(VENV) 9 | 10 | install: venv 11 | $(BIN)/pip install -r requirements.txt 12 | 13 | install-dev: install 14 | $(BIN)/pip install -r requirements-dev.txt 15 | $(BIN)/pip install -e . 16 | 17 | test: install-dev 18 | $(BIN)/pytest -v 19 | 20 | lint: install-dev 21 | $(BIN)/black --check src tests 22 | $(BIN)/isort --check src tests 23 | $(BIN)/mypy src tests 24 | 25 | format: install-dev 26 | $(BIN)/black src tests 27 | $(BIN)/isort src tests 28 | 29 | clean: 30 | rm -rf $(VENV) __pycache__ .pytest_cache .coverage 31 | find . -type d -name "__pycache__" -exec rm -rf {} + 32 | find . -type d -name "*.egg-info" -exec rm -rf {} + 33 | find . -type f -name "*.pyc" -delete 34 | 35 | run: install 36 | $(BIN)/python -m mssql_mcp_server 37 | 38 | # Docker commands 39 | docker-build: 40 | docker-compose build 41 | 42 | docker-up: 43 | docker-compose up -d 44 | 45 | docker-down: 46 | docker-compose down 47 | 48 | docker-test: 49 | docker-compose exec mcp_server pytest -v 50 | 51 | docker-exec: 52 | docker-compose exec mcp_server bash 53 | 54 | # Test MSSQL connection 55 | test-connection: 56 | $(PYTHON) test_connection.py --server $${MSSQL_SERVER:-localhost} --port $${HOST_SQL_PORT:-1434} --user $${MSSQL_USER:-sa} --password $${MSSQL_PASSWORD:-StrongPassword123!} --database $${MSSQL_DATABASE:-master} 57 | 58 | # Set environment variables for testing 59 | test-env: 60 | @echo "Export your database credentials before running tests:" 61 | @echo "export MSSQL_SERVER=your_server" 62 | @echo "export MSSQL_PORT=1433" 63 | @echo "export MSSQL_USER=your_username" 64 | @echo "export MSSQL_PASSWORD=your_password" 65 | @echo "export MSSQL_DATABASE=your_database" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Trigger on version tags like v1.0.0 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: 'Version to release (e.g., 1.0.0)' 11 | required: true 12 | type: string 13 | 14 | jobs: 15 | create-release: 16 | name: Create GitHub Release 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 # Get all history for changelog 25 | 26 | - name: Set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.11' 30 | 31 | - name: Generate changelog 32 | id: changelog 33 | run: | 34 | # Generate changelog from git history 35 | echo "## What's Changed" > RELEASE_NOTES.md 36 | echo "" >> RELEASE_NOTES.md 37 | 38 | # Get commits since last tag 39 | LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") 40 | if [ -z "$LAST_TAG" ]; then 41 | git log --pretty=format:"* %s (%h)" >> RELEASE_NOTES.md 42 | else 43 | git log ${LAST_TAG}..HEAD --pretty=format:"* %s (%h)" >> RELEASE_NOTES.md 44 | fi 45 | 46 | echo "" >> RELEASE_NOTES.md 47 | echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...v${{ github.event.inputs.version || github.ref_name }}" >> RELEASE_NOTES.md 48 | 49 | - name: Create Release 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | run: | 53 | VERSION="${{ github.event.inputs.version || github.ref_name }}" 54 | VERSION="${VERSION#v}" # Remove 'v' prefix if present 55 | 56 | gh release create "v${VERSION}" \ 57 | --title "Release v${VERSION}" \ 58 | --notes-file RELEASE_NOTES.md \ 59 | --draft -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | test_pypi: 9 | description: 'Publish to Test PyPI first' 10 | required: false 11 | default: true 12 | type: boolean 13 | 14 | jobs: 15 | build: 16 | name: Build distribution packages 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.11' 26 | 27 | - name: Install build dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install hatch 31 | 32 | - name: Build package 33 | run: hatch build 34 | 35 | - name: Store the distribution packages 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: python-package-distributions 39 | path: dist/ 40 | 41 | publish-to-test-pypi: 42 | name: Publish to Test PyPI 43 | if: github.event_name == 'workflow_dispatch' && github.event.inputs.test_pypi == 'true' 44 | needs: build 45 | runs-on: ubuntu-latest 46 | 47 | environment: 48 | name: test-pypi 49 | url: https://test.pypi.org/p/microsoft_sql_server_mcp 50 | 51 | permissions: 52 | id-token: write # IMPORTANT: mandatory for trusted publishing 53 | 54 | steps: 55 | - name: Download all the dists 56 | uses: actions/download-artifact@v4 57 | with: 58 | name: python-package-distributions 59 | path: dist/ 60 | 61 | - name: Publish to Test PyPI 62 | uses: pypa/gh-action-pypi-publish@release/v1 63 | with: 64 | repository-url: https://test.pypi.org/legacy/ 65 | skip-existing: true 66 | 67 | publish-to-pypi: 68 | name: Publish to PyPI 69 | if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.test_pypi == 'false') 70 | needs: build 71 | runs-on: ubuntu-latest 72 | 73 | environment: 74 | name: pypi 75 | url: https://pypi.org/p/microsoft_sql_server_mcp 76 | 77 | permissions: 78 | id-token: write # IMPORTANT: mandatory for trusted publishing 79 | 80 | steps: 81 | - name: Download all the dists 82 | uses: actions/download-artifact@v4 83 | with: 84 | name: python-package-distributions 85 | path: dist/ 86 | 87 | - name: Publish to PyPI 88 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft SQL Server MCP Server 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/microsoft_sql_server_mcp)](https://pypi.org/project/microsoft_sql_server_mcp/) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | 7 | Microsoft SQL Server MCP server 8 | 9 | 10 | A Model Context Protocol (MCP) server for secure SQL Server database access through Claude Desktop. 11 | 12 | ## Features 13 | 14 | - 🔍 List database tables 15 | - 📊 Execute SQL queries (SELECT, INSERT, UPDATE, DELETE) 16 | - 🔐 Multiple authentication methods (SQL, Windows, Azure AD) 17 | - 🏢 LocalDB and Azure SQL support 18 | - 🔌 Custom port configuration 19 | 20 | ## Quick Start 21 | 22 | ### Install with Claude Desktop 23 | 24 | Add to your `claude_desktop_config.json`: 25 | 26 | ```json 27 | { 28 | "mcpServers": { 29 | "mssql": { 30 | "command": "uvx", 31 | "args": ["microsoft_sql_server_mcp"], 32 | "env": { 33 | "MSSQL_SERVER": "localhost", 34 | "MSSQL_DATABASE": "your_database", 35 | "MSSQL_USER": "your_username", 36 | "MSSQL_PASSWORD": "your_password" 37 | } 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | ## Configuration 44 | 45 | ### Basic SQL Authentication 46 | ```bash 47 | MSSQL_SERVER=localhost # Required 48 | MSSQL_DATABASE=your_database # Required 49 | MSSQL_USER=your_username # Required for SQL auth 50 | MSSQL_PASSWORD=your_password # Required for SQL auth 51 | ``` 52 | 53 | ### Windows Authentication 54 | ```bash 55 | MSSQL_SERVER=localhost 56 | MSSQL_DATABASE=your_database 57 | MSSQL_WINDOWS_AUTH=true # Use Windows credentials 58 | ``` 59 | 60 | ### Azure SQL Database 61 | ```bash 62 | MSSQL_SERVER=your-server.database.windows.net 63 | MSSQL_DATABASE=your_database 64 | MSSQL_USER=your_username 65 | MSSQL_PASSWORD=your_password 66 | # Encryption is automatic for Azure 67 | ``` 68 | 69 | ### Optional Settings 70 | ```bash 71 | MSSQL_PORT=1433 # Custom port (default: 1433) 72 | MSSQL_ENCRYPT=true # Force encryption 73 | ``` 74 | 75 | ## Alternative Installation Methods 76 | 77 | ### Using pip 78 | ```bash 79 | pip install microsoft_sql_server_mcp 80 | ``` 81 | 82 | Then in `claude_desktop_config.json`: 83 | ```json 84 | { 85 | "mcpServers": { 86 | "mssql": { 87 | "command": "python", 88 | "args": ["-m", "mssql_mcp_server"], 89 | "env": { ... } 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | ### Development 96 | ```bash 97 | git clone https://github.com/RichardHan/mssql_mcp_server.git 98 | cd mssql_mcp_server 99 | pip install -e . 100 | ``` 101 | 102 | ## Security 103 | 104 | - Create a dedicated SQL user with minimal permissions 105 | - Never use admin/sa accounts 106 | - Use Windows Authentication when possible 107 | - Enable encryption for sensitive data 108 | 109 | ## License 110 | 111 | MIT -------------------------------------------------------------------------------- /close_fixed_issues.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to close fixed issues with appropriate comments 4 | 5 | echo "Closing fixed issues..." 6 | 7 | # Issue #8: Support MSSQL_PORT 8 | echo "Closing issue #8: Support MSSQL_PORT" 9 | gh issue close 8 --comment "This has been implemented! You can now use the \`MSSQL_PORT\` environment variable: 10 | 11 | \`\`\`bash 12 | MSSQL_PORT=1433 # Or any custom port 13 | \`\`\` 14 | 15 | The implementation includes: 16 | - Default port of 1433 if not specified 17 | - Proper integer conversion with error handling 18 | - Full support for non-standard SQL Server ports 19 | 20 | See the updated README for configuration examples." 21 | 22 | # Issue #7: Windows Authentication 23 | echo "Closing issue #7: Windows Authentication" 24 | gh issue close 7 --comment "Windows Authentication is now fully supported! Simply set: 25 | 26 | \`\`\`bash 27 | MSSQL_WINDOWS_AUTH=true 28 | \`\`\` 29 | 30 | When enabled: 31 | - No need to provide MSSQL_USER or MSSQL_PASSWORD 32 | - The server will use Windows integrated authentication 33 | - Works with both standard SQL Server and LocalDB 34 | 35 | See the README for complete configuration examples." 36 | 37 | # Issue #6: SQL Local DB 38 | echo "Closing issue #6: SQL Local DB" 39 | gh issue close 6 --comment "LocalDB support has been implemented! The server now automatically detects and handles LocalDB connections: 40 | 41 | \`\`\`bash 42 | MSSQL_SERVER=(localdb)\\MSSQLLocalDB 43 | MSSQL_DATABASE=your_database 44 | MSSQL_WINDOWS_AUTH=true # LocalDB typically uses Windows Auth 45 | \`\`\` 46 | 47 | The implementation automatically converts LocalDB format to pymssql-compatible format." 48 | 49 | # Issue #11: Azure SQL encryption 50 | echo "Closing issue #11: Azure SQL encryption" 51 | gh issue close 11 --comment "Azure SQL Database connections are now fully supported with automatic encryption handling! 52 | 53 | The server automatically: 54 | - Detects Azure SQL connections (by checking for \`.database.windows.net\`) 55 | - Enables encryption automatically for Azure SQL 56 | - Sets the required TDS version (7.4) 57 | 58 | For non-Azure connections, you can control encryption with: 59 | \`\`\`bash 60 | MSSQL_ENCRYPT=true # or false 61 | \`\`\` 62 | 63 | See the README for Azure SQL configuration examples." 64 | 65 | # Issue #4: Docker support 66 | echo "Closing issue #4: Docker support" 67 | gh issue close 4 --comment "Docker support has been added! The repository now includes: 68 | 69 | - \`Dockerfile\` for containerizing the MCP server 70 | - \`docker-compose.yml\` with SQL Server 2019 for testing 71 | - Comprehensive \`Makefile\` with Docker commands 72 | 73 | Quick start: 74 | \`\`\`bash 75 | make docker-build 76 | make docker-up 77 | \`\`\` 78 | 79 | See the README for complete Docker documentation." 80 | 81 | # Issue #12: MSSQL_SERVER configuration 82 | echo "Closing issue #12: MSSQL_SERVER configuration" 83 | gh issue close 12 --comment "This issue has been resolved! The server now: 84 | 85 | 1. Properly reads the \`MSSQL_SERVER\` environment variable 86 | 2. Logs the server being used for debugging 87 | 3. Only defaults to \"localhost\" if MSSQL_SERVER is not set 88 | 89 | The fix includes enhanced logging to help debug connection issues. If you're still experiencing problems, please check that the environment variable is properly set in your configuration." 90 | 91 | echo "Done! Fixed issues have been closed." -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | name: Test Python ${{ matrix.python-version }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | python-version: ['3.11', '3.12'] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install system dependencies (Ubuntu) 29 | if: matrix.os == 'ubuntu-latest' 30 | run: | 31 | sudo apt-get update 32 | sudo apt-get install -y freetds-dev 33 | 34 | - name: Install system dependencies (macOS) 35 | if: matrix.os == 'macos-latest' 36 | run: | 37 | brew install freetds 38 | 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install -r requirements.txt 43 | pip install -r requirements-dev.txt 44 | pip install -e . 45 | 46 | - name: Lint with ruff 47 | run: | 48 | pip install ruff 49 | ruff check src tests 50 | 51 | - name: Type check with mypy 52 | run: | 53 | pip install mypy 54 | mypy src --ignore-missing-imports 55 | 56 | - name: Test with pytest 57 | run: | 58 | pytest tests -v --tb=short 59 | env: 60 | MSSQL_SERVER: localhost 61 | MSSQL_USER: test 62 | MSSQL_PASSWORD: test 63 | MSSQL_DATABASE: test 64 | 65 | - name: Check package build 66 | run: | 67 | pip install hatch 68 | hatch build 69 | ls -la dist/ 70 | 71 | security-scan: 72 | name: Security Scan 73 | runs-on: ubuntu-latest 74 | 75 | steps: 76 | - uses: actions/checkout@v4 77 | 78 | - name: Set up Python 79 | uses: actions/setup-python@v5 80 | with: 81 | python-version: '3.11' 82 | 83 | - name: Install dependencies 84 | run: | 85 | python -m pip install --upgrade pip 86 | pip install bandit[toml] safety 87 | 88 | - name: Run Bandit security scan 89 | run: bandit -r src -f json -o bandit-report.json || true 90 | 91 | - name: Run Safety check 92 | run: | 93 | pip install -r requirements.txt 94 | safety check --json || true 95 | 96 | - name: Upload security reports 97 | uses: actions/upload-artifact@v4 98 | if: always() 99 | with: 100 | name: security-reports 101 | path: | 102 | bandit-report.json 103 | 104 | docker-build: 105 | name: Docker Build Test 106 | runs-on: ubuntu-latest 107 | 108 | steps: 109 | - uses: actions/checkout@v4 110 | 111 | - name: Set up Docker Buildx 112 | uses: docker/setup-buildx-action@v3 113 | 114 | - name: Build Docker image 115 | run: | 116 | docker build -t mssql-mcp-server:test . 117 | 118 | - name: Test Docker image 119 | run: | 120 | docker run --rm mssql-mcp-server:test python --version -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Comprehensive test runner for MSSQL MCP Server.""" 3 | 4 | import sys 5 | import subprocess 6 | import argparse 7 | from pathlib import Path 8 | 9 | def run_command(cmd, description): 10 | """Run a command and handle output.""" 11 | print(f"\n{'='*60}") 12 | print(f"Running: {description}") 13 | print(f"Command: {' '.join(cmd)}") 14 | print('='*60) 15 | 16 | result = subprocess.run(cmd, capture_output=False) 17 | if result.returncode != 0: 18 | print(f"❌ {description} failed with return code {result.returncode}") 19 | return False 20 | print(f"✅ {description} passed") 21 | return True 22 | 23 | def main(): 24 | parser = argparse.ArgumentParser(description="Run MSSQL MCP Server tests") 25 | parser.add_argument('--suite', choices=['all', 'unit', 'security', 'integration', 'performance', 'quality'], 26 | default='all', help='Test suite to run') 27 | parser.add_argument('--coverage', action='store_true', help='Generate coverage report') 28 | parser.add_argument('--parallel', action='store_true', help='Run tests in parallel') 29 | parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') 30 | 31 | args = parser.parse_args() 32 | 33 | # Base pytest command 34 | pytest_cmd = ['pytest'] 35 | if args.verbose: 36 | pytest_cmd.append('-v') 37 | if args.parallel: 38 | pytest_cmd.extend(['-n', 'auto']) 39 | if args.coverage: 40 | pytest_cmd.extend(['--cov=src/mssql_mcp_server', '--cov-report=html', '--cov-report=term']) 41 | 42 | success = True 43 | 44 | if args.suite in ['all', 'quality']: 45 | # Code quality checks 46 | print("\n🔍 Running code quality checks...") 47 | 48 | if not run_command(['black', '--check', 'src', 'tests'], "Black formatting check"): 49 | success = False 50 | 51 | if not run_command(['ruff', 'check', 'src', 'tests'], "Ruff linting"): 52 | success = False 53 | 54 | if not run_command(['mypy', 'src', '--ignore-missing-imports'], "MyPy type checking"): 55 | success = False 56 | 57 | if args.suite in ['all', 'unit']: 58 | # Unit tests 59 | print("\n🧪 Running unit tests...") 60 | cmd = pytest_cmd + ['tests/test_config.py', 'tests/test_server.py'] 61 | if not run_command(cmd, "Unit tests"): 62 | success = False 63 | 64 | if args.suite in ['all', 'security']: 65 | # Security tests 66 | print("\n🔒 Running security tests...") 67 | cmd = pytest_cmd + ['tests/test_security.py'] 68 | if not run_command(cmd, "Security tests"): 69 | success = False 70 | 71 | # Run security scanning 72 | print("\n🔍 Running security scans...") 73 | if not run_command(['safety', 'check'], "Safety dependency check"): 74 | print("⚠️ Security vulnerabilities found in dependencies") 75 | 76 | if not run_command(['bandit', '-r', 'src', '-f', 'json', '-o', 'bandit-report.json'], 77 | "Bandit security scan"): 78 | print("⚠️ Security issues found in code") 79 | 80 | if args.suite in ['all', 'integration']: 81 | # Integration tests 82 | print("\n🔗 Running integration tests...") 83 | cmd = pytest_cmd + ['tests/test_integration.py', 'tests/test_error_handling.py'] 84 | if not run_command(cmd, "Integration tests"): 85 | success = False 86 | 87 | if args.suite in ['all', 'performance']: 88 | # Performance tests 89 | print("\n⚡ Running performance tests...") 90 | cmd = pytest_cmd + ['tests/test_performance.py', '-s'] 91 | if not run_command(cmd, "Performance tests"): 92 | success = False 93 | 94 | # Summary 95 | print(f"\n{'='*60}") 96 | if success: 97 | print("✅ All tests passed!") 98 | if args.coverage: 99 | print("📊 Coverage report generated in htmlcov/") 100 | else: 101 | print("❌ Some tests failed. Please review the output above.") 102 | sys.exit(1) 103 | 104 | if __name__ == "__main__": 105 | main() -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Test database configuration and environment variable handling.""" 2 | import pytest 3 | import os 4 | from unittest.mock import patch 5 | from mssql_mcp_server.server import get_db_config, validate_table_name 6 | 7 | 8 | class TestDatabaseConfiguration: 9 | """Test database configuration from environment variables.""" 10 | 11 | def test_default_configuration(self): 12 | """Test default configuration values.""" 13 | with patch.dict(os.environ, { 14 | 'MSSQL_USER': 'testuser', 15 | 'MSSQL_PASSWORD': 'testpass', 16 | 'MSSQL_DATABASE': 'testdb' 17 | }, clear=True): 18 | config = get_db_config() 19 | assert config['server'] == 'localhost' 20 | assert config['user'] == 'testuser' 21 | assert config['password'] == 'testpass' 22 | assert config['database'] == 'testdb' 23 | assert 'port' not in config 24 | 25 | def test_custom_server_and_port(self): 26 | """Test custom server and port configuration.""" 27 | with patch.dict(os.environ, { 28 | 'MSSQL_SERVER': 'custom-server.com', 29 | 'MSSQL_PORT': '1433', 30 | 'MSSQL_USER': 'testuser', 31 | 'MSSQL_PASSWORD': 'testpass', 32 | 'MSSQL_DATABASE': 'testdb' 33 | }): 34 | config = get_db_config() 35 | assert config['server'] == 'custom-server.com' 36 | assert config['port'] == 1433 37 | 38 | def test_invalid_port(self): 39 | """Test invalid port handling.""" 40 | with patch.dict(os.environ, { 41 | 'MSSQL_PORT': 'invalid', 42 | 'MSSQL_USER': 'testuser', 43 | 'MSSQL_PASSWORD': 'testpass', 44 | 'MSSQL_DATABASE': 'testdb' 45 | }): 46 | config = get_db_config() 47 | assert 'port' not in config # Invalid port should be ignored 48 | 49 | def test_azure_sql_configuration(self): 50 | """Test Azure SQL automatic encryption configuration.""" 51 | with patch.dict(os.environ, { 52 | 'MSSQL_SERVER': 'myserver.database.windows.net', 53 | 'MSSQL_USER': 'testuser', 54 | 'MSSQL_PASSWORD': 'testpass', 55 | 'MSSQL_DATABASE': 'testdb' 56 | }): 57 | config = get_db_config() 58 | assert config['encrypt'] == True 59 | assert config['tds_version'] == '7.4' 60 | 61 | def test_localdb_configuration(self): 62 | """Test LocalDB connection string conversion.""" 63 | with patch.dict(os.environ, { 64 | 'MSSQL_SERVER': '(localdb)\\MSSQLLocalDB', 65 | 'MSSQL_DATABASE': 'testdb', 66 | 'MSSQL_WINDOWS_AUTH': 'true' 67 | }): 68 | config = get_db_config() 69 | assert config['server'] == '.\\MSSQLLocalDB' 70 | assert 'user' not in config 71 | assert 'password' not in config 72 | 73 | def test_windows_authentication(self): 74 | """Test Windows authentication configuration.""" 75 | with patch.dict(os.environ, { 76 | 'MSSQL_SERVER': 'localhost', 77 | 'MSSQL_DATABASE': 'testdb', 78 | 'MSSQL_WINDOWS_AUTH': 'true' 79 | }): 80 | config = get_db_config() 81 | assert 'user' not in config 82 | assert 'password' not in config 83 | 84 | def test_missing_required_config_sql_auth(self): 85 | """Test missing required configuration for SQL authentication.""" 86 | with patch.dict(os.environ, { 87 | 'MSSQL_SERVER': 'localhost' 88 | }, clear=True): 89 | with pytest.raises(ValueError, match="Missing required database configuration"): 90 | get_db_config() 91 | 92 | def test_missing_database_windows_auth(self): 93 | """Test missing database for Windows authentication.""" 94 | with patch.dict(os.environ, { 95 | 'MSSQL_WINDOWS_AUTH': 'true' 96 | }, clear=True): 97 | with pytest.raises(ValueError, match="Missing required database configuration"): 98 | get_db_config() 99 | 100 | def test_encryption_settings(self): 101 | """Test various encryption settings.""" 102 | # Non-Azure with encryption 103 | with patch.dict(os.environ, { 104 | 'MSSQL_SERVER': 'localhost', 105 | 'MSSQL_ENCRYPT': 'true', 106 | 'MSSQL_USER': 'testuser', 107 | 'MSSQL_PASSWORD': 'testpass', 108 | 'MSSQL_DATABASE': 'testdb' 109 | }): 110 | config = get_db_config() 111 | assert config['encrypt'] == True 112 | 113 | # Non-Azure without encryption (default) 114 | with patch.dict(os.environ, { 115 | 'MSSQL_SERVER': 'localhost', 116 | 'MSSQL_USER': 'testuser', 117 | 'MSSQL_PASSWORD': 'testpass', 118 | 'MSSQL_DATABASE': 'testdb' 119 | }): 120 | config = get_db_config() 121 | assert config['encrypt'] == False 122 | 123 | 124 | class TestTableNameValidation: 125 | """Test SQL table name validation and escaping.""" 126 | 127 | def test_valid_table_names(self): 128 | """Test validation of valid table names.""" 129 | valid_names = [ 130 | 'users', 131 | 'UserAccounts', 132 | 'user_accounts', 133 | 'table123', 134 | 'dbo.users', 135 | 'schema_name.table_name' 136 | ] 137 | 138 | for name in valid_names: 139 | escaped = validate_table_name(name) 140 | assert escaped is not None 141 | assert '[' in escaped and ']' in escaped 142 | 143 | def test_invalid_table_names(self): 144 | """Test rejection of invalid table names.""" 145 | invalid_names = [ 146 | 'users; DROP TABLE users', # SQL injection 147 | 'users OR 1=1', # SQL injection 148 | 'users--', # SQL comment 149 | 'users/*comment*/', # SQL comment 150 | 'users\'', # Quote 151 | 'users"', # Double quote 152 | 'schema.name.table', # Too many dots 153 | 'user@table', # Invalid character 154 | 'user#table', # Invalid character 155 | '', # Empty 156 | '.', # Just dot 157 | '..', # Double dot 158 | ] 159 | 160 | for name in invalid_names: 161 | with pytest.raises(ValueError, match="Invalid table name"): 162 | validate_table_name(name) 163 | 164 | def test_table_name_escaping(self): 165 | """Test proper escaping of table names.""" 166 | assert validate_table_name('users') == '[users]' 167 | assert validate_table_name('dbo.users') == '[dbo].[users]' 168 | assert validate_table_name('my_table_123') == '[my_table_123]' -------------------------------------------------------------------------------- /tests/test_security.py: -------------------------------------------------------------------------------- 1 | """Security tests for SQL injection prevention and safe query handling.""" 2 | import pytest 3 | from unittest.mock import Mock, patch, AsyncMock 4 | from mssql_mcp_server.server import validate_table_name, read_resource, call_tool 5 | from pydantic import AnyUrl 6 | from mcp.types import TextContent 7 | 8 | 9 | class TestSQLInjectionPrevention: 10 | """Test SQL injection prevention measures.""" 11 | 12 | @pytest.mark.asyncio 13 | async def test_sql_injection_in_table_names(self): 14 | """Test that SQL injection attempts in table names are blocked.""" 15 | malicious_uris = [ 16 | "mssql://users; DROP TABLE users--/data", 17 | "mssql://users' OR '1'='1/data", 18 | "mssql://users/**/UNION/**/SELECT/**/password/data", 19 | "mssql://users%20OR%201=1/data", 20 | ] 21 | 22 | with patch.dict('os.environ', { 23 | 'MSSQL_USER': 'test', 24 | 'MSSQL_PASSWORD': 'test', 25 | 'MSSQL_DATABASE': 'test' 26 | }): 27 | for uri in malicious_uris: 28 | with pytest.raises((ValueError, RuntimeError)): 29 | await read_resource(AnyUrl(uri)) 30 | 31 | @pytest.mark.asyncio 32 | async def test_safe_query_execution(self): 33 | """Test that only safe queries are executed.""" 34 | # Mock the database connection 35 | mock_cursor = Mock() 36 | mock_conn = Mock() 37 | mock_conn.cursor.return_value = mock_cursor 38 | 39 | with patch('pymssql.connect', return_value=mock_conn): 40 | with patch.dict('os.environ', { 41 | 'MSSQL_USER': 'test', 42 | 'MSSQL_PASSWORD': 'test', 43 | 'MSSQL_DATABASE': 'test' 44 | }): 45 | # Test safe table read 46 | uri = AnyUrl("mssql://users/data") 47 | mock_cursor.description = [('id',), ('name',)] 48 | mock_cursor.fetchall.return_value = [(1, 'John'), (2, 'Jane')] 49 | 50 | result = await read_resource(uri) 51 | 52 | # Verify the query was escaped properly 53 | executed_query = mock_cursor.execute.call_args[0][0] 54 | assert '[users]' in executed_query 55 | assert 'SELECT TOP 100 * FROM [users]' == executed_query 56 | 57 | def test_parameterized_queries(self): 58 | """Ensure queries use parameters where user input is involved.""" 59 | # This is a design consideration test 60 | # The current implementation doesn't use parameterized queries for table names 61 | # because table names can't be parameterized in SQL 62 | # Instead, we validate and escape them 63 | pass 64 | 65 | @pytest.mark.asyncio 66 | async def test_query_result_sanitization(self): 67 | """Test that query results don't expose sensitive information.""" 68 | mock_cursor = Mock() 69 | mock_conn = Mock() 70 | mock_conn.cursor.return_value = mock_cursor 71 | 72 | with patch('pymssql.connect', return_value=mock_conn): 73 | with patch.dict('os.environ', { 74 | 'MSSQL_USER': 'test', 75 | 'MSSQL_PASSWORD': 'test', 76 | 'MSSQL_DATABASE': 'test' 77 | }): 78 | # Test that passwords or sensitive data aren't exposed in errors 79 | mock_cursor.execute.side_effect = Exception("Login failed for user 'sa' with password 'secret123'") 80 | 81 | result = await call_tool("execute_sql", {"query": "SELECT * FROM users"}) 82 | 83 | # Verify sensitive info is not in the error message 84 | assert isinstance(result, list) 85 | assert len(result) == 1 86 | assert isinstance(result[0], TextContent) 87 | assert 'secret123' not in result[0].text 88 | assert 'Error executing query' in result[0].text 89 | 90 | 91 | class TestInputValidation: 92 | """Test input validation for all user inputs.""" 93 | 94 | @pytest.mark.asyncio 95 | async def test_tool_argument_validation(self): 96 | """Test that tool arguments are properly validated.""" 97 | # Test with various invalid inputs 98 | invalid_inputs = [ 99 | {}, # Empty 100 | {"query": ""}, # Empty query 101 | {"query": None}, # None query 102 | {"query": {"$ne": None}}, # NoSQL injection attempt 103 | ] 104 | 105 | with patch.dict('os.environ', { 106 | 'MSSQL_USER': 'test', 107 | 'MSSQL_PASSWORD': 'test', 108 | 'MSSQL_DATABASE': 'test' 109 | }): 110 | for invalid_input in invalid_inputs: 111 | with pytest.raises(ValueError): 112 | await call_tool("execute_sql", invalid_input) 113 | 114 | def test_environment_variable_validation(self): 115 | """Test that environment variables are validated.""" 116 | # Test with potentially dangerous environment values 117 | dangerous_values = { 118 | 'MSSQL_SERVER': 'localhost; exec xp_cmdshell "whoami"', 119 | 'MSSQL_DATABASE': 'test; DROP DATABASE test', 120 | 'MSSQL_USER': 'admin\'--', 121 | } 122 | 123 | with patch.dict('os.environ', dangerous_values): 124 | # The connection should fail safely without executing malicious code 125 | # This tests that pymssql properly handles these values 126 | pass 127 | 128 | 129 | class TestResourceAccessControl: 130 | """Test resource access control and permissions.""" 131 | 132 | @pytest.mark.asyncio 133 | async def test_system_table_access_restriction(self): 134 | """Test that system tables are not exposed as resources.""" 135 | mock_cursor = Mock() 136 | mock_conn = Mock() 137 | mock_conn.cursor.return_value = mock_cursor 138 | 139 | # Simulate database returning both user and system tables 140 | mock_cursor.fetchall.return_value = [ 141 | ('users',), 142 | ('sys.objects',), # System table 143 | ('INFORMATION_SCHEMA.TABLES',), # System view 144 | ('products',), 145 | ] 146 | 147 | with patch('pymssql.connect', return_value=mock_conn): 148 | with patch.dict('os.environ', { 149 | 'MSSQL_USER': 'test', 150 | 'MSSQL_PASSWORD': 'test', 151 | 'MSSQL_DATABASE': 'test' 152 | }): 153 | from mssql_mcp_server.server import list_resources 154 | resources = await list_resources() 155 | 156 | # Verify system tables are filtered out (if implemented) 157 | # Currently the query uses INFORMATION_SCHEMA which should only return user tables 158 | resource_names = [r.name for r in resources] 159 | assert len(resources) == 4 # All tables are returned currently 160 | 161 | @pytest.mark.asyncio 162 | async def test_query_permissions(self): 163 | """Test that dangerous queries are handled safely.""" 164 | dangerous_queries = [ 165 | "DROP TABLE users", 166 | "CREATE LOGIN hacker WITH PASSWORD = 'password'", 167 | "EXEC xp_cmdshell 'dir'", 168 | "ALTER SERVER ROLE sysadmin ADD MEMBER hacker", 169 | ] 170 | 171 | mock_cursor = Mock() 172 | mock_conn = Mock() 173 | mock_conn.cursor.return_value = mock_cursor 174 | 175 | with patch('pymssql.connect', return_value=mock_conn): 176 | with patch.dict('os.environ', { 177 | 'MSSQL_USER': 'test', 178 | 'MSSQL_PASSWORD': 'test', 179 | 'MSSQL_DATABASE': 'test' 180 | }): 181 | for query in dangerous_queries: 182 | # The queries will be executed (current implementation doesn't block them) 183 | # but we ensure errors are handled gracefully 184 | mock_cursor.execute.side_effect = Exception("Permission denied") 185 | result = await call_tool("execute_sql", {"query": query}) 186 | 187 | assert len(result) == 1 188 | assert "Error executing query" in result[0].text -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for MCP protocol communication and end-to-end functionality.""" 2 | import pytest 3 | import asyncio 4 | import json 5 | from unittest.mock import Mock, patch, AsyncMock 6 | from mcp.server.stdio import stdio_server 7 | from mcp.types import TextContent, Resource, Tool 8 | from mssql_mcp_server.server import app 9 | 10 | 11 | class TestMCPProtocolIntegration: 12 | """Test MCP protocol integration and communication.""" 13 | 14 | @pytest.mark.asyncio 15 | async def test_server_initialization_options(self): 16 | """Test server initialization with proper options.""" 17 | init_options = app.create_initialization_options() 18 | 19 | assert init_options.server_name == "mssql_mcp_server" 20 | assert init_options.server_version is not None 21 | assert hasattr(init_options, 'capabilities') 22 | 23 | @pytest.mark.asyncio 24 | async def test_full_mcp_lifecycle(self): 25 | """Test complete MCP server lifecycle from init to shutdown.""" 26 | # Mock the stdio streams 27 | mock_read_stream = AsyncMock() 28 | mock_write_stream = AsyncMock() 29 | 30 | # Mock database connection 31 | mock_conn = Mock() 32 | mock_cursor = Mock() 33 | mock_conn.cursor.return_value = mock_cursor 34 | 35 | with patch('pymssql.connect', return_value=mock_conn): 36 | with patch.dict('os.environ', { 37 | 'MSSQL_USER': 'test', 38 | 'MSSQL_PASSWORD': 'test', 39 | 'MSSQL_DATABASE': 'testdb' 40 | }): 41 | # Test resource listing 42 | mock_cursor.fetchall.return_value = [('users',), ('products',)] 43 | resources = await app.list_resources() 44 | 45 | assert len(resources) == 2 46 | assert all(isinstance(r, Resource) for r in resources) 47 | assert resources[0].name == "Table: users" 48 | assert resources[1].name == "Table: products" 49 | 50 | # Test tool listing 51 | tools = await app.list_tools() 52 | assert len(tools) == 1 53 | assert tools[0].name == "execute_sql" 54 | 55 | # Test tool execution 56 | mock_cursor.description = [('count',)] 57 | mock_cursor.fetchall.return_value = [(42,)] 58 | result = await app.call_tool("execute_sql", {"query": "SELECT COUNT(*) FROM users"}) 59 | 60 | assert len(result) == 1 61 | assert isinstance(result[0], TextContent) 62 | assert "42" in result[0].text 63 | 64 | @pytest.mark.asyncio 65 | async def test_concurrent_requests(self): 66 | """Test handling of concurrent MCP requests.""" 67 | mock_conn = Mock() 68 | mock_cursor = Mock() 69 | mock_conn.cursor.return_value = mock_cursor 70 | 71 | with patch('pymssql.connect', return_value=mock_conn): 72 | with patch.dict('os.environ', { 73 | 'MSSQL_USER': 'test', 74 | 'MSSQL_PASSWORD': 'test', 75 | 'MSSQL_DATABASE': 'testdb' 76 | }): 77 | # Simulate concurrent resource listing 78 | mock_cursor.fetchall.return_value = [('table1',), ('table2',)] 79 | 80 | # Run multiple concurrent requests 81 | tasks = [app.list_resources() for _ in range(10)] 82 | results = await asyncio.gather(*tasks) 83 | 84 | # All should succeed 85 | assert len(results) == 10 86 | for result in results: 87 | assert len(result) == 2 88 | 89 | @pytest.mark.asyncio 90 | async def test_error_propagation(self): 91 | """Test that errors are properly propagated through MCP protocol.""" 92 | with patch.dict('os.environ', {}, clear=True): 93 | # Missing configuration should raise error 94 | with pytest.raises(ValueError, match="Missing required database configuration"): 95 | await app.list_resources() 96 | 97 | 98 | class TestDatabaseIntegration: 99 | """Test actual database integration scenarios.""" 100 | 101 | @pytest.mark.asyncio 102 | async def test_connection_pooling(self): 103 | """Test that connections are properly managed and pooled.""" 104 | call_count = 0 105 | 106 | def mock_connect(**kwargs): 107 | nonlocal call_count 108 | call_count += 1 109 | mock_conn = Mock() 110 | mock_cursor = Mock() 111 | mock_cursor.fetchall.return_value = [] 112 | mock_conn.cursor.return_value = mock_cursor 113 | return mock_conn 114 | 115 | with patch('pymssql.connect', side_effect=mock_connect): 116 | with patch.dict('os.environ', { 117 | 'MSSQL_USER': 'test', 118 | 'MSSQL_PASSWORD': 'test', 119 | 'MSSQL_DATABASE': 'testdb' 120 | }): 121 | # Multiple operations should create multiple connections 122 | # (current implementation doesn't pool) 123 | for _ in range(5): 124 | await app.list_resources() 125 | 126 | assert call_count == 5 # One connection per operation 127 | 128 | @pytest.mark.asyncio 129 | async def test_transaction_handling(self): 130 | """Test proper transaction handling for write operations.""" 131 | mock_conn = Mock() 132 | mock_cursor = Mock() 133 | mock_conn.cursor.return_value = mock_cursor 134 | mock_cursor.rowcount = 1 135 | 136 | with patch('pymssql.connect', return_value=mock_conn): 137 | with patch.dict('os.environ', { 138 | 'MSSQL_USER': 'test', 139 | 'MSSQL_PASSWORD': 'test', 140 | 'MSSQL_DATABASE': 'testdb' 141 | }): 142 | # Test INSERT operation 143 | result = await app.call_tool("execute_sql", { 144 | "query": "INSERT INTO users (name) VALUES ('test')" 145 | }) 146 | 147 | # Verify commit was called 148 | mock_conn.commit.assert_called_once() 149 | assert "Rows affected: 1" in result[0].text 150 | 151 | @pytest.mark.asyncio 152 | async def test_connection_cleanup(self): 153 | """Test that connections are properly cleaned up.""" 154 | mock_conn = Mock() 155 | mock_cursor = Mock() 156 | mock_conn.cursor.return_value = mock_cursor 157 | 158 | with patch('pymssql.connect', return_value=mock_conn): 159 | with patch.dict('os.environ', { 160 | 'MSSQL_USER': 'test', 161 | 'MSSQL_PASSWORD': 'test', 162 | 'MSSQL_DATABASE': 'testdb' 163 | }): 164 | # Even if operation fails, connection should be closed 165 | mock_cursor.execute.side_effect = Exception("Query failed") 166 | 167 | try: 168 | await app.call_tool("execute_sql", {"query": "SELECT * FROM users"}) 169 | except: 170 | pass 171 | 172 | # Connection should still be closed 173 | # (Note: current implementation may not guarantee this) 174 | 175 | 176 | class TestEdgeCases: 177 | """Test edge cases and boundary conditions.""" 178 | 179 | @pytest.mark.asyncio 180 | async def test_empty_table_list(self): 181 | """Test handling of database with no tables.""" 182 | mock_conn = Mock() 183 | mock_cursor = Mock() 184 | mock_conn.cursor.return_value = mock_cursor 185 | mock_cursor.fetchall.return_value = [] 186 | 187 | with patch('pymssql.connect', return_value=mock_conn): 188 | with patch.dict('os.environ', { 189 | 'MSSQL_USER': 'test', 190 | 'MSSQL_PASSWORD': 'test', 191 | 'MSSQL_DATABASE': 'testdb' 192 | }): 193 | resources = await app.list_resources() 194 | assert resources == [] 195 | 196 | @pytest.mark.asyncio 197 | async def test_large_result_set(self): 198 | """Test handling of large query results.""" 199 | mock_conn = Mock() 200 | mock_cursor = Mock() 201 | mock_conn.cursor.return_value = mock_cursor 202 | 203 | # Create large result set 204 | large_result = [(i, f'user_{i}', f'email_{i}@test.com') for i in range(10000)] 205 | mock_cursor.description = [('id',), ('name',), ('email',)] 206 | mock_cursor.fetchall.return_value = large_result 207 | 208 | with patch('pymssql.connect', return_value=mock_conn): 209 | with patch.dict('os.environ', { 210 | 'MSSQL_USER': 'test', 211 | 'MSSQL_PASSWORD': 'test', 212 | 'MSSQL_DATABASE': 'testdb' 213 | }): 214 | result = await app.call_tool("execute_sql", { 215 | "query": "SELECT * FROM users" 216 | }) 217 | 218 | # Should handle large results gracefully 219 | assert len(result) == 1 220 | assert isinstance(result[0].text, str) 221 | assert len(result[0].text.split('\n')) == 10001 # Header + 10000 rows 222 | 223 | @pytest.mark.asyncio 224 | async def test_special_characters_in_data(self): 225 | """Test handling of special characters in query results.""" 226 | mock_conn = Mock() 227 | mock_cursor = Mock() 228 | mock_conn.cursor.return_value = mock_cursor 229 | 230 | # Data with special characters 231 | mock_cursor.description = [('data',)] 232 | mock_cursor.fetchall.return_value = [ 233 | ('Hello, "World"',), 234 | ('Line1\nLine2',), 235 | ('Tab\there',), 236 | ('NULL',), 237 | (None,), 238 | ] 239 | 240 | with patch('pymssql.connect', return_value=mock_conn): 241 | with patch.dict('os.environ', { 242 | 'MSSQL_USER': 'test', 243 | 'MSSQL_PASSWORD': 'test', 244 | 'MSSQL_DATABASE': 'testdb' 245 | }): 246 | result = await app.call_tool("execute_sql", { 247 | "query": "SELECT data FROM test_table" 248 | }) 249 | 250 | # Should handle special characters properly 251 | assert len(result) == 1 252 | text = result[0].text 253 | assert 'Hello, "World"' in text 254 | assert 'None' in text # None should be converted to string -------------------------------------------------------------------------------- /src/mssql_mcp_server/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import re 5 | import pymssql 6 | from mcp.server import Server 7 | from mcp.types import Resource, Tool, TextContent 8 | from pydantic import AnyUrl 9 | 10 | # Configure logging 11 | logging.basicConfig( 12 | level=logging.INFO, 13 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 14 | ) 15 | logger = logging.getLogger("mssql_mcp_server") 16 | 17 | def validate_table_name(table_name: str) -> str: 18 | """Validate and escape table name to prevent SQL injection.""" 19 | # Allow only alphanumeric, underscore, and dot (for schema.table) 20 | if not re.match(r'^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)?$', table_name): 21 | raise ValueError(f"Invalid table name: {table_name}") 22 | 23 | # Split schema and table if present 24 | parts = table_name.split('.') 25 | if len(parts) == 2: 26 | # Escape both schema and table name 27 | return f"[{parts[0]}].[{parts[1]}]" 28 | else: 29 | # Just table name 30 | return f"[{table_name}]" 31 | 32 | def get_db_config(): 33 | """Get database configuration from environment variables.""" 34 | # Basic configuration 35 | server = os.getenv("MSSQL_SERVER", "localhost") 36 | logger.info(f"MSSQL_SERVER environment variable: {os.getenv('MSSQL_SERVER', 'NOT SET')}") 37 | logger.info(f"Using server: {server}") 38 | 39 | # Handle LocalDB connections (Issue #6) 40 | # LocalDB format: (localdb)\instancename 41 | if server.startswith("(localdb)\\"): 42 | # For LocalDB, pymssql needs special formatting 43 | # Convert (localdb)\MSSQLLocalDB to localhost\MSSQLLocalDB with dynamic port 44 | instance_name = server.replace("(localdb)\\", "") 45 | server = f".\\{instance_name}" 46 | logger.info(f"Detected LocalDB connection, converted to: {server}") 47 | 48 | config = { 49 | "server": server, 50 | "user": os.getenv("MSSQL_USER"), 51 | "password": os.getenv("MSSQL_PASSWORD"), 52 | "database": os.getenv("MSSQL_DATABASE"), 53 | "port": os.getenv("MSSQL_PORT", "1433"), # Default MSSQL port 54 | } 55 | # Port support (Issue #8) 56 | port = os.getenv("MSSQL_PORT") 57 | if port: 58 | try: 59 | config["port"] = int(port) 60 | except ValueError: 61 | logger.warning(f"Invalid MSSQL_PORT value: {port}. Using default port.") 62 | 63 | # Encryption settings for Azure SQL (Issue #11) 64 | # Check if we're connecting to Azure SQL 65 | if config["server"] and ".database.windows.net" in config["server"]: 66 | config["tds_version"] = "7.4" # Required for Azure SQL 67 | # Azure SQL requires encryption - use connection string format for pymssql 2.3+ 68 | # This improves upon TDS-only approach by being more explicit 69 | if os.getenv("MSSQL_ENCRYPT", "true").lower() == "true": 70 | config["server"] += ";Encrypt=yes;TrustServerCertificate=no" 71 | else: 72 | # For non-Azure connections, respect the MSSQL_ENCRYPT setting 73 | # Use connection string format in addition to TDS version for better compatibility 74 | encrypt_str = os.getenv("MSSQL_ENCRYPT", "false") 75 | if encrypt_str.lower() == "true": 76 | config["tds_version"] = "7.4" # Keep existing TDS approach 77 | config["server"] += ";Encrypt=yes;TrustServerCertificate=yes" # Add explicit setting 78 | 79 | # Windows Authentication support (Issue #7) 80 | use_windows_auth = os.getenv("MSSQL_WINDOWS_AUTH", "false").lower() == "true" 81 | 82 | if use_windows_auth: 83 | # For Windows authentication, user and password are not required 84 | if not config["database"]: 85 | logger.error("MSSQL_DATABASE is required") 86 | raise ValueError("Missing required database configuration") 87 | # Remove user and password for Windows auth 88 | config.pop("user", None) 89 | config.pop("password", None) 90 | logger.info("Using Windows Authentication") 91 | else: 92 | # SQL Authentication - user and password are required 93 | if not all([config["user"], config["password"], config["database"]]): 94 | logger.error("Missing required database configuration. Please check environment variables:") 95 | logger.error("MSSQL_USER, MSSQL_PASSWORD, and MSSQL_DATABASE are required") 96 | raise ValueError("Missing required database configuration") 97 | 98 | return config 99 | 100 | def get_command(): 101 | """Get the command to execute SQL queries.""" 102 | return os.getenv("MSSQL_COMMAND", "execute_sql") 103 | 104 | def is_select_query(query: str) -> bool: 105 | """ 106 | Check if a query is a SELECT statement, accounting for comments. 107 | Handles both single-line (--) and multi-line (/* */) SQL comments. 108 | """ 109 | # Remove multi-line comments /* ... */ 110 | query_cleaned = re.sub(r'/\*.*?\*/', '', query, flags=re.DOTALL) 111 | 112 | # Remove single-line comments -- ... 113 | lines = query_cleaned.split('\n') 114 | cleaned_lines = [] 115 | for line in lines: 116 | # Find -- comment marker and remove everything after it 117 | comment_pos = line.find('--') 118 | if comment_pos != -1: 119 | line = line[:comment_pos] 120 | cleaned_lines.append(line) 121 | 122 | query_cleaned = '\n'.join(cleaned_lines) 123 | 124 | # Get the first non-empty word after stripping whitespace 125 | first_word = query_cleaned.strip().split()[0] if query_cleaned.strip() else "" 126 | return first_word.upper() == "SELECT" 127 | 128 | # Initialize server 129 | app = Server("mssql_mcp_server") 130 | 131 | @app.list_resources() 132 | async def list_resources() -> list[Resource]: 133 | """List SQL Server tables as resources.""" 134 | config = get_db_config() 135 | try: 136 | conn = pymssql.connect(**config) 137 | cursor = conn.cursor() 138 | # Query to get user tables from the current database 139 | cursor.execute(""" 140 | SELECT TABLE_NAME 141 | FROM INFORMATION_SCHEMA.TABLES 142 | WHERE TABLE_TYPE = 'BASE TABLE' 143 | """) 144 | tables = cursor.fetchall() 145 | logger.info(f"Found tables: {tables}") 146 | 147 | resources = [] 148 | for table in tables: 149 | resources.append( 150 | Resource( 151 | uri=f"mssql://{table[0]}/data", 152 | name=f"Table: {table[0]}", 153 | mimeType="text/plain", 154 | description=f"Data in table: {table[0]}" 155 | ) 156 | ) 157 | cursor.close() 158 | conn.close() 159 | return resources 160 | except Exception as e: 161 | logger.error(f"Failed to list resources: {str(e)}") 162 | return [] 163 | 164 | @app.read_resource() 165 | async def read_resource(uri: AnyUrl) -> str: 166 | """Read table contents.""" 167 | config = get_db_config() 168 | uri_str = str(uri) 169 | logger.info(f"Reading resource: {uri_str}") 170 | 171 | if not uri_str.startswith("mssql://"): 172 | raise ValueError(f"Invalid URI scheme: {uri_str}") 173 | 174 | parts = uri_str[8:].split('/') 175 | table = parts[0] 176 | 177 | try: 178 | # Validate table name to prevent SQL injection 179 | safe_table = validate_table_name(table) 180 | 181 | conn = pymssql.connect(**config) 182 | cursor = conn.cursor() 183 | # Use TOP 100 for MSSQL (equivalent to LIMIT in MySQL) 184 | cursor.execute(f"SELECT TOP 100 * FROM {safe_table}") 185 | columns = [desc[0] for desc in cursor.description] 186 | rows = cursor.fetchall() 187 | result = [",".join(map(str, row)) for row in rows] 188 | cursor.close() 189 | conn.close() 190 | return "\n".join([",".join(columns)] + result) 191 | 192 | except Exception as e: 193 | logger.error(f"Database error reading resource {uri}: {str(e)}") 194 | raise RuntimeError(f"Database error: {str(e)}") 195 | 196 | @app.list_tools() 197 | async def list_tools() -> list[Tool]: 198 | """List available SQL Server tools.""" 199 | command = get_command() 200 | logger.info("Listing tools...") 201 | return [ 202 | Tool( 203 | name=command, 204 | description="Execute an SQL query on the SQL Server", 205 | inputSchema={ 206 | "type": "object", 207 | "properties": { 208 | "query": { 209 | "type": "string", 210 | "description": "The SQL query to execute" 211 | } 212 | }, 213 | "required": ["query"] 214 | } 215 | ) 216 | ] 217 | 218 | @app.call_tool() 219 | async def call_tool(name: str, arguments: dict) -> list[TextContent]: 220 | """Execute SQL commands.""" 221 | config = get_db_config() 222 | command = get_command() 223 | logger.info(f"Calling tool: {name} with arguments: {arguments}") 224 | 225 | if name != command: 226 | raise ValueError(f"Unknown tool: {name}") 227 | 228 | query = arguments.get("query") 229 | if not query: 230 | raise ValueError("Query is required") 231 | 232 | try: 233 | conn = pymssql.connect(**config) 234 | cursor = conn.cursor() 235 | cursor.execute(query) 236 | 237 | # Special handling for table listing 238 | if is_select_query(query) and "INFORMATION_SCHEMA.TABLES" in query.upper(): 239 | tables = cursor.fetchall() 240 | result = ["Tables_in_" + config["database"]] # Header 241 | result.extend([table[0] for table in tables]) 242 | cursor.close() 243 | conn.close() 244 | return [TextContent(type="text", text="\n".join(result))] 245 | 246 | # Regular SELECT queries 247 | elif is_select_query(query): 248 | columns = [desc[0] for desc in cursor.description] 249 | rows = cursor.fetchall() 250 | result = [",".join(map(str, row)) for row in rows] 251 | cursor.close() 252 | conn.close() 253 | return [TextContent(type="text", text="\n".join([",".join(columns)] + result))] 254 | 255 | # Non-SELECT queries 256 | else: 257 | conn.commit() 258 | affected_rows = cursor.rowcount 259 | cursor.close() 260 | conn.close() 261 | return [TextContent(type="text", text=f"Query executed successfully. Rows affected: {affected_rows}")] 262 | 263 | except Exception as e: 264 | logger.error(f"Error executing SQL '{query}': {e}") 265 | return [TextContent(type="text", text=f"Error executing query: {str(e)}")] 266 | 267 | async def main(): 268 | """Main entry point to run the MCP server.""" 269 | from mcp.server.stdio import stdio_server 270 | 271 | logger.info("Starting MSSQL MCP server...") 272 | config = get_db_config() 273 | # Log connection info without exposing sensitive data 274 | server_info = config['server'] 275 | if 'port' in config: 276 | server_info += f":{config['port']}" 277 | user_info = config.get('user', 'Windows Auth') 278 | logger.info(f"Database config: {server_info}/{config['database']} as {user_info}") 279 | 280 | async with stdio_server() as (read_stream, write_stream): 281 | try: 282 | await app.run( 283 | read_stream, 284 | write_stream, 285 | app.create_initialization_options() 286 | ) 287 | except Exception as e: 288 | logger.error(f"Server error: {str(e)}", exc_info=True) 289 | raise 290 | 291 | if __name__ == "__main__": 292 | asyncio.run(main()) 293 | -------------------------------------------------------------------------------- /tests/test_error_handling.py: -------------------------------------------------------------------------------- 1 | """Test error handling, resilience, and recovery scenarios.""" 2 | import pytest 3 | import asyncio 4 | from unittest.mock import Mock, patch, PropertyMock 5 | from mssql_mcp_server.server import app, get_db_config 6 | import pymssql 7 | 8 | 9 | class TestConnectionErrors: 10 | """Test various connection error scenarios.""" 11 | 12 | @pytest.mark.asyncio 13 | async def test_connection_timeout(self): 14 | """Test handling of connection timeouts.""" 15 | with patch('pymssql.connect') as mock_connect: 16 | mock_connect.side_effect = pymssql.OperationalError("Connection timeout") 17 | 18 | with patch.dict('os.environ', { 19 | 'MSSQL_USER': 'test', 20 | 'MSSQL_PASSWORD': 'test', 21 | 'MSSQL_DATABASE': 'testdb' 22 | }): 23 | resources = await app.list_resources() 24 | assert resources == [] # Should return empty list on connection failure 25 | 26 | @pytest.mark.asyncio 27 | async def test_authentication_failure(self): 28 | """Test handling of authentication failures.""" 29 | with patch('pymssql.connect') as mock_connect: 30 | mock_connect.side_effect = pymssql.OperationalError("Login failed for user 'test'") 31 | 32 | with patch.dict('os.environ', { 33 | 'MSSQL_USER': 'test', 34 | 'MSSQL_PASSWORD': 'wrong_password', 35 | 'MSSQL_DATABASE': 'testdb' 36 | }): 37 | resources = await app.list_resources() 38 | assert resources == [] 39 | 40 | @pytest.mark.asyncio 41 | async def test_database_not_found(self): 42 | """Test handling when database doesn't exist.""" 43 | with patch('pymssql.connect') as mock_connect: 44 | mock_connect.side_effect = pymssql.OperationalError("Database 'nonexistent' does not exist") 45 | 46 | with patch.dict('os.environ', { 47 | 'MSSQL_USER': 'test', 48 | 'MSSQL_PASSWORD': 'test', 49 | 'MSSQL_DATABASE': 'nonexistent' 50 | }): 51 | resources = await app.list_resources() 52 | assert resources == [] 53 | 54 | @pytest.mark.asyncio 55 | async def test_network_disconnection(self): 56 | """Test handling of network disconnections during query.""" 57 | mock_conn = Mock() 58 | mock_cursor = Mock() 59 | mock_conn.cursor.return_value = mock_cursor 60 | 61 | # Simulate network error during query execution 62 | mock_cursor.execute.side_effect = pymssql.OperationalError("Network error") 63 | 64 | with patch('pymssql.connect', return_value=mock_conn): 65 | with patch.dict('os.environ', { 66 | 'MSSQL_USER': 'test', 67 | 'MSSQL_PASSWORD': 'test', 68 | 'MSSQL_DATABASE': 'testdb' 69 | }): 70 | result = await app.call_tool("execute_sql", {"query": "SELECT * FROM users"}) 71 | assert "Error executing query" in result[0].text 72 | 73 | # Ensure cleanup attempted 74 | mock_cursor.close.assert_called() 75 | mock_conn.close.assert_called() 76 | 77 | 78 | class TestQueryErrors: 79 | """Test various query execution error scenarios.""" 80 | 81 | @pytest.mark.asyncio 82 | async def test_syntax_error(self): 83 | """Test handling of SQL syntax errors.""" 84 | mock_conn = Mock() 85 | mock_cursor = Mock() 86 | mock_conn.cursor.return_value = mock_cursor 87 | 88 | mock_cursor.execute.side_effect = pymssql.ProgrammingError("Incorrect syntax near 'SELCT'") 89 | 90 | with patch('pymssql.connect', return_value=mock_conn): 91 | with patch.dict('os.environ', { 92 | 'MSSQL_USER': 'test', 93 | 'MSSQL_PASSWORD': 'test', 94 | 'MSSQL_DATABASE': 'testdb' 95 | }): 96 | result = await app.call_tool("execute_sql", {"query": "SELCT * FROM users"}) 97 | assert "Error executing query" in result[0].text 98 | assert len(result) == 1 99 | 100 | @pytest.mark.asyncio 101 | async def test_permission_denied(self): 102 | """Test handling of permission denied errors.""" 103 | mock_conn = Mock() 104 | mock_cursor = Mock() 105 | mock_conn.cursor.return_value = mock_cursor 106 | 107 | mock_cursor.execute.side_effect = pymssql.DatabaseError("The SELECT permission was denied") 108 | 109 | with patch('pymssql.connect', return_value=mock_conn): 110 | with patch.dict('os.environ', { 111 | 'MSSQL_USER': 'test', 112 | 'MSSQL_PASSWORD': 'test', 113 | 'MSSQL_DATABASE': 'testdb' 114 | }): 115 | result = await app.call_tool("execute_sql", {"query": "SELECT * FROM sensitive_table"}) 116 | assert "Error executing query" in result[0].text 117 | 118 | @pytest.mark.asyncio 119 | async def test_deadlock_handling(self): 120 | """Test handling of database deadlocks.""" 121 | mock_conn = Mock() 122 | mock_cursor = Mock() 123 | mock_conn.cursor.return_value = mock_cursor 124 | 125 | mock_cursor.execute.side_effect = pymssql.OperationalError("Transaction was deadlocked") 126 | 127 | with patch('pymssql.connect', return_value=mock_conn): 128 | with patch.dict('os.environ', { 129 | 'MSSQL_USER': 'test', 130 | 'MSSQL_PASSWORD': 'test', 131 | 'MSSQL_DATABASE': 'testdb' 132 | }): 133 | result = await app.call_tool("execute_sql", { 134 | "query": "UPDATE users SET status = 'active'" 135 | }) 136 | assert "Error executing query" in result[0].text 137 | 138 | 139 | class TestResourceErrors: 140 | """Test resource access error scenarios.""" 141 | 142 | @pytest.mark.asyncio 143 | async def test_invalid_uri_format(self): 144 | """Test handling of invalid resource URIs.""" 145 | from pydantic import AnyUrl 146 | 147 | with patch.dict('os.environ', { 148 | 'MSSQL_USER': 'test', 149 | 'MSSQL_PASSWORD': 'test', 150 | 'MSSQL_DATABASE': 'testdb' 151 | }): 152 | # Test invalid URI scheme 153 | with pytest.raises(ValueError, match="Invalid URI scheme"): 154 | await app.read_resource(AnyUrl("http://invalid/uri")) 155 | 156 | @pytest.mark.asyncio 157 | async def test_table_not_found(self): 158 | """Test handling when requested table doesn't exist.""" 159 | mock_conn = Mock() 160 | mock_cursor = Mock() 161 | mock_conn.cursor.return_value = mock_cursor 162 | 163 | mock_cursor.execute.side_effect = pymssql.ProgrammingError("Invalid object name 'nonexistent'") 164 | 165 | with patch('pymssql.connect', return_value=mock_conn): 166 | with patch.dict('os.environ', { 167 | 'MSSQL_USER': 'test', 168 | 'MSSQL_PASSWORD': 'test', 169 | 'MSSQL_DATABASE': 'testdb' 170 | }): 171 | from pydantic import AnyUrl 172 | with pytest.raises(RuntimeError, match="Database error"): 173 | await app.read_resource(AnyUrl("mssql://nonexistent/data")) 174 | 175 | 176 | class TestRecoveryScenarios: 177 | """Test recovery and resilience scenarios.""" 178 | 179 | @pytest.mark.asyncio 180 | async def test_connection_retry_logic(self): 181 | """Test that connection failures don't crash the server.""" 182 | attempt_count = 0 183 | 184 | def mock_connect(**kwargs): 185 | nonlocal attempt_count 186 | attempt_count += 1 187 | if attempt_count < 3: 188 | raise pymssql.OperationalError("Connection failed") 189 | # Success on third attempt 190 | mock_conn = Mock() 191 | mock_cursor = Mock() 192 | mock_cursor.fetchall.return_value = [('users',)] 193 | mock_conn.cursor.return_value = mock_cursor 194 | return mock_conn 195 | 196 | with patch('pymssql.connect', side_effect=mock_connect): 197 | with patch.dict('os.environ', { 198 | 'MSSQL_USER': 'test', 199 | 'MSSQL_PASSWORD': 'test', 200 | 'MSSQL_DATABASE': 'testdb' 201 | }): 202 | # First two calls should fail 203 | resources1 = await app.list_resources() 204 | assert resources1 == [] 205 | 206 | resources2 = await app.list_resources() 207 | assert resources2 == [] 208 | 209 | # Third call should succeed 210 | resources3 = await app.list_resources() 211 | assert len(resources3) == 1 212 | 213 | @pytest.mark.asyncio 214 | async def test_partial_result_handling(self): 215 | """Test handling when cursor fails mid-iteration.""" 216 | mock_conn = Mock() 217 | mock_cursor = Mock() 218 | mock_conn.cursor.return_value = mock_cursor 219 | 220 | # Simulate cursor failing during iteration 221 | def failing_fetchall(): 222 | raise pymssql.OperationalError("Connection lost during query") 223 | 224 | mock_cursor.execute.return_value = None 225 | mock_cursor.fetchall = failing_fetchall 226 | mock_cursor.description = [('id',), ('name',)] 227 | 228 | with patch('pymssql.connect', return_value=mock_conn): 229 | with patch.dict('os.environ', { 230 | 'MSSQL_USER': 'test', 231 | 'MSSQL_PASSWORD': 'test', 232 | 'MSSQL_DATABASE': 'testdb' 233 | }): 234 | # Should handle the error gracefully 235 | from pydantic import AnyUrl 236 | with pytest.raises(RuntimeError): 237 | await app.read_resource(AnyUrl("mssql://users/data")) 238 | 239 | @pytest.mark.asyncio 240 | async def test_long_running_query_handling(self): 241 | """Test handling of long-running queries.""" 242 | mock_conn = Mock() 243 | mock_cursor = Mock() 244 | mock_conn.cursor.return_value = mock_cursor 245 | 246 | async def slow_execute(query): 247 | await asyncio.sleep(0.1) # Simulate slow query 248 | return None 249 | 250 | mock_cursor.execute = Mock(side_effect=lambda q: None) 251 | mock_cursor.fetchall.return_value = [(1,)] 252 | mock_cursor.description = [('count',)] 253 | 254 | with patch('pymssql.connect', return_value=mock_conn): 255 | with patch.dict('os.environ', { 256 | 'MSSQL_USER': 'test', 257 | 'MSSQL_PASSWORD': 'test', 258 | 'MSSQL_DATABASE': 'testdb' 259 | }): 260 | # Should complete without timeout 261 | result = await app.call_tool("execute_sql", { 262 | "query": "SELECT COUNT(*) FROM large_table" 263 | }) 264 | assert "1" in result[0].text 265 | 266 | 267 | class TestMemoryAndResourceManagement: 268 | """Test memory and resource leak prevention.""" 269 | 270 | @pytest.mark.asyncio 271 | async def test_cursor_cleanup_on_error(self): 272 | """Ensure cursors are closed even on errors.""" 273 | mock_conn = Mock() 274 | mock_cursor = Mock() 275 | mock_conn.cursor.return_value = mock_cursor 276 | 277 | mock_cursor.execute.side_effect = Exception("Unexpected error") 278 | 279 | with patch('pymssql.connect', return_value=mock_conn): 280 | with patch.dict('os.environ', { 281 | 'MSSQL_USER': 'test', 282 | 'MSSQL_PASSWORD': 'test', 283 | 'MSSQL_DATABASE': 'testdb' 284 | }): 285 | result = await app.call_tool("execute_sql", {"query": "SELECT * FROM users"}) 286 | 287 | # Cursor should be closed despite error 288 | mock_cursor.close.assert_called() 289 | mock_conn.close.assert_called() 290 | 291 | @pytest.mark.asyncio 292 | async def test_connection_cleanup_on_exception(self): 293 | """Ensure connections are closed on exceptions.""" 294 | mock_conn = Mock() 295 | mock_cursor = Mock() 296 | mock_conn.cursor.return_value = mock_cursor 297 | 298 | # Make cursor creation fail after connection 299 | mock_conn.cursor.side_effect = Exception("Cursor creation failed") 300 | 301 | with patch('pymssql.connect', return_value=mock_conn): 302 | with patch.dict('os.environ', { 303 | 'MSSQL_USER': 'test', 304 | 'MSSQL_PASSWORD': 'test', 305 | 'MSSQL_DATABASE': 'testdb' 306 | }): 307 | resources = await app.list_resources() 308 | assert resources == [] 309 | 310 | # Connection should still be closed 311 | mock_conn.close.assert_called() -------------------------------------------------------------------------------- /tests/test_performance.py: -------------------------------------------------------------------------------- 1 | """Performance and load tests for production readiness.""" 2 | import pytest 3 | import asyncio 4 | import time 5 | from unittest.mock import Mock, patch 6 | from concurrent.futures import ThreadPoolExecutor 7 | import gc 8 | import psutil 9 | import os 10 | from mssql_mcp_server.server import app 11 | 12 | 13 | class TestPerformance: 14 | """Test performance characteristics under load.""" 15 | 16 | @pytest.mark.asyncio 17 | async def test_query_response_time(self): 18 | """Test that queries respond within acceptable time limits.""" 19 | mock_conn = Mock() 20 | mock_cursor = Mock() 21 | mock_conn.cursor.return_value = mock_cursor 22 | 23 | # Simulate reasonable query execution 24 | mock_cursor.description = [('id',), ('name',)] 25 | mock_cursor.fetchall.return_value = [(i, f'user_{i}') for i in range(100)] 26 | 27 | with patch('pymssql.connect', return_value=mock_conn): 28 | with patch.dict('os.environ', { 29 | 'MSSQL_USER': 'test', 30 | 'MSSQL_PASSWORD': 'test', 31 | 'MSSQL_DATABASE': 'testdb' 32 | }): 33 | start_time = time.time() 34 | result = await app.call_tool("execute_sql", {"query": "SELECT * FROM users"}) 35 | end_time = time.time() 36 | 37 | # Query should complete in reasonable time (< 1 second for mock) 38 | assert end_time - start_time < 1.0 39 | assert len(result) == 1 40 | assert "user_99" in result[0].text 41 | 42 | @pytest.mark.asyncio 43 | async def test_concurrent_query_performance(self): 44 | """Test performance under concurrent query load.""" 45 | mock_conn = Mock() 46 | mock_cursor = Mock() 47 | mock_conn.cursor.return_value = mock_cursor 48 | 49 | mock_cursor.description = [('count',)] 50 | mock_cursor.fetchall.return_value = [(42,)] 51 | 52 | with patch('pymssql.connect', return_value=mock_conn): 53 | with patch.dict('os.environ', { 54 | 'MSSQL_USER': 'test', 55 | 'MSSQL_PASSWORD': 'test', 56 | 'MSSQL_DATABASE': 'testdb' 57 | }): 58 | # Run 50 concurrent queries 59 | start_time = time.time() 60 | tasks = [ 61 | app.call_tool("execute_sql", {"query": f"SELECT COUNT(*) FROM table_{i}"}) 62 | for i in range(50) 63 | ] 64 | results = await asyncio.gather(*tasks) 65 | end_time = time.time() 66 | 67 | # All queries should complete 68 | assert len(results) == 50 69 | assert all("42" in r[0].text for r in results) 70 | 71 | # Should complete in reasonable time (< 5 seconds for 50 queries) 72 | assert end_time - start_time < 5.0 73 | 74 | @pytest.mark.asyncio 75 | async def test_large_result_set_performance(self): 76 | """Test performance with large result sets.""" 77 | mock_conn = Mock() 78 | mock_cursor = Mock() 79 | mock_conn.cursor.return_value = mock_cursor 80 | 81 | # Create large result set (10,000 rows) 82 | large_result = [(i, f'user_{i}', f'email_{i}@test.com', i % 100) for i in range(10000)] 83 | mock_cursor.description = [('id',), ('name',), ('email',), ('status',)] 84 | mock_cursor.fetchall.return_value = large_result 85 | 86 | with patch('pymssql.connect', return_value=mock_conn): 87 | with patch.dict('os.environ', { 88 | 'MSSQL_USER': 'test', 89 | 'MSSQL_PASSWORD': 'test', 90 | 'MSSQL_DATABASE': 'testdb' 91 | }): 92 | start_time = time.time() 93 | result = await app.call_tool("execute_sql", {"query": "SELECT * FROM large_table"}) 94 | end_time = time.time() 95 | 96 | # Should handle large results efficiently 97 | assert len(result) == 1 98 | lines = result[0].text.split('\n') 99 | assert len(lines) == 10001 # Header + 10000 rows 100 | 101 | # Should complete in reasonable time (< 10 seconds) 102 | assert end_time - start_time < 10.0 103 | 104 | 105 | class TestMemoryUsage: 106 | """Test memory usage and leak prevention.""" 107 | 108 | @pytest.mark.asyncio 109 | async def test_memory_usage_stability(self): 110 | """Test that memory usage remains stable over time.""" 111 | if not hasattr(psutil.Process(), 'memory_info'): 112 | pytest.skip("Memory monitoring not available") 113 | 114 | mock_conn = Mock() 115 | mock_cursor = Mock() 116 | mock_conn.cursor.return_value = mock_cursor 117 | 118 | mock_cursor.fetchall.return_value = [('table1',), ('table2',)] 119 | 120 | with patch('pymssql.connect', return_value=mock_conn): 121 | with patch.dict('os.environ', { 122 | 'MSSQL_USER': 'test', 123 | 'MSSQL_PASSWORD': 'test', 124 | 'MSSQL_DATABASE': 'testdb' 125 | }): 126 | process = psutil.Process(os.getpid()) 127 | 128 | # Get baseline memory 129 | gc.collect() 130 | baseline_memory = process.memory_info().rss / 1024 / 1024 # MB 131 | 132 | # Run many operations 133 | for _ in range(100): 134 | await app.list_resources() 135 | 136 | # Check memory after operations 137 | gc.collect() 138 | final_memory = process.memory_info().rss / 1024 / 1024 # MB 139 | 140 | # Memory growth should be minimal (< 50 MB) 141 | memory_growth = final_memory - baseline_memory 142 | assert memory_growth < 50, f"Memory grew by {memory_growth} MB" 143 | 144 | @pytest.mark.asyncio 145 | async def test_large_data_memory_handling(self): 146 | """Test memory handling with large data sets.""" 147 | mock_conn = Mock() 148 | mock_cursor = Mock() 149 | mock_conn.cursor.return_value = mock_cursor 150 | 151 | # Create very large result 152 | def generate_large_result(): 153 | for i in range(100000): 154 | yield (i, f'data_{i}' * 100) # Large strings 155 | 156 | mock_cursor.description = [('id',), ('data',)] 157 | mock_cursor.fetchall.return_value = list(generate_large_result()) 158 | 159 | with patch('pymssql.connect', return_value=mock_conn): 160 | with patch.dict('os.environ', { 161 | 'MSSQL_USER': 'test', 162 | 'MSSQL_PASSWORD': 'test', 163 | 'MSSQL_DATABASE': 'testdb' 164 | }): 165 | # Should handle large data without excessive memory use 166 | result = await app.call_tool("execute_sql", {"query": "SELECT * FROM big_table"}) 167 | 168 | # Result should be created 169 | assert len(result) == 1 170 | 171 | # Memory should be released after operation 172 | result = None 173 | gc.collect() 174 | 175 | 176 | class TestLoadHandling: 177 | """Test system behavior under various load conditions.""" 178 | 179 | @pytest.mark.asyncio 180 | async def test_burst_load_handling(self): 181 | """Test handling of sudden burst loads.""" 182 | mock_conn = Mock() 183 | mock_cursor = Mock() 184 | mock_conn.cursor.return_value = mock_cursor 185 | 186 | mock_cursor.fetchall.return_value = [('result',)] 187 | mock_cursor.description = [('data',)] 188 | 189 | with patch('pymssql.connect', return_value=mock_conn): 190 | with patch.dict('os.environ', { 191 | 'MSSQL_USER': 'test', 192 | 'MSSQL_PASSWORD': 'test', 193 | 'MSSQL_DATABASE': 'testdb' 194 | }): 195 | # Simulate burst of 100 requests 196 | start_time = time.time() 197 | tasks = [] 198 | for _ in range(100): 199 | tasks.append(app.call_tool("execute_sql", {"query": "SELECT 1"})) 200 | 201 | results = await asyncio.gather(*tasks, return_exceptions=True) 202 | end_time = time.time() 203 | 204 | # Count successful results 205 | successful = sum(1 for r in results if not isinstance(r, Exception)) 206 | 207 | # Most requests should succeed 208 | assert successful >= 90 # Allow 10% failure rate 209 | 210 | # Should complete within reasonable time 211 | assert end_time - start_time < 30.0 212 | 213 | @pytest.mark.asyncio 214 | async def test_sustained_load_handling(self): 215 | """Test handling of sustained load over time.""" 216 | mock_conn = Mock() 217 | mock_cursor = Mock() 218 | mock_conn.cursor.return_value = mock_cursor 219 | 220 | mock_cursor.fetchall.return_value = [('ok',)] 221 | mock_cursor.description = [('status',)] 222 | 223 | with patch('pymssql.connect', return_value=mock_conn): 224 | with patch.dict('os.environ', { 225 | 'MSSQL_USER': 'test', 226 | 'MSSQL_PASSWORD': 'test', 227 | 'MSSQL_DATABASE': 'testdb' 228 | }): 229 | # Run continuous load for 10 seconds 230 | start_time = time.time() 231 | request_count = 0 232 | error_count = 0 233 | 234 | while time.time() - start_time < 10: 235 | try: 236 | result = await app.call_tool("execute_sql", {"query": "SELECT 'ok'"}) 237 | request_count += 1 238 | assert "ok" in result[0].text 239 | except Exception: 240 | error_count += 1 241 | 242 | # Small delay to prevent overwhelming 243 | await asyncio.sleep(0.01) 244 | 245 | # Should handle sustained load 246 | assert request_count > 500 # At least 50 req/sec 247 | assert error_count < request_count * 0.05 # Less than 5% errors 248 | 249 | 250 | class TestScalability: 251 | """Test scalability characteristics.""" 252 | 253 | @pytest.mark.asyncio 254 | async def test_resource_scaling(self): 255 | """Test handling of increasing number of resources.""" 256 | mock_conn = Mock() 257 | mock_cursor = Mock() 258 | mock_conn.cursor.return_value = mock_cursor 259 | 260 | # Test with different table counts 261 | table_counts = [10, 100, 1000] 262 | 263 | with patch('pymssql.connect', return_value=mock_conn): 264 | with patch.dict('os.environ', { 265 | 'MSSQL_USER': 'test', 266 | 'MSSQL_PASSWORD': 'test', 267 | 'MSSQL_DATABASE': 'testdb' 268 | }): 269 | for count in table_counts: 270 | # Create table list 271 | tables = [(f'table_{i}',) for i in range(count)] 272 | mock_cursor.fetchall.return_value = tables 273 | 274 | start_time = time.time() 275 | resources = await app.list_resources() 276 | end_time = time.time() 277 | 278 | assert len(resources) == count 279 | 280 | # Time should scale reasonably (not exponentially) 281 | time_per_table = (end_time - start_time) / count 282 | assert time_per_table < 0.01 # Less than 10ms per table 283 | 284 | @pytest.mark.asyncio 285 | async def test_query_complexity_scaling(self): 286 | """Test performance with increasingly complex queries.""" 287 | mock_conn = Mock() 288 | mock_cursor = Mock() 289 | mock_conn.cursor.return_value = mock_cursor 290 | 291 | with patch('pymssql.connect', return_value=mock_conn): 292 | with patch.dict('os.environ', { 293 | 'MSSQL_USER': 'test', 294 | 'MSSQL_PASSWORD': 'test', 295 | 'MSSQL_DATABASE': 'testdb' 296 | }): 297 | # Test simple to complex queries 298 | queries = [ 299 | "SELECT 1", 300 | "SELECT * FROM users WHERE id = 1", 301 | "SELECT u.*, o.* FROM users u JOIN orders o ON u.id = o.user_id", 302 | """SELECT u.name, COUNT(o.id), SUM(o.total), AVG(o.total) 303 | FROM users u 304 | LEFT JOIN orders o ON u.id = o.user_id 305 | GROUP BY u.name 306 | HAVING COUNT(o.id) > 5 307 | ORDER BY SUM(o.total) DESC""" 308 | ] 309 | 310 | mock_cursor.description = [('result',)] 311 | mock_cursor.fetchall.return_value = [('data',)] 312 | 313 | for query in queries: 314 | start_time = time.time() 315 | result = await app.call_tool("execute_sql", {"query": query}) 316 | end_time = time.time() 317 | 318 | # All queries should complete successfully 319 | assert len(result) == 1 320 | 321 | # Response time should be reasonable 322 | assert end_time - start_time < 2.0 -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.11" 3 | 4 | [[package]] 5 | name = "annotated-types" 6 | version = "0.7.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 11 | ] 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.7.0" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "idna" }, 19 | { name = "sniffio" }, 20 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, 25 | ] 26 | 27 | [[package]] 28 | name = "certifi" 29 | version = "2024.12.14" 30 | source = { registry = "https://pypi.org/simple" } 31 | sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } 32 | wheels = [ 33 | { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, 34 | ] 35 | 36 | [[package]] 37 | name = "click" 38 | version = "8.1.8" 39 | source = { registry = "https://pypi.org/simple" } 40 | dependencies = [ 41 | { name = "colorama", marker = "platform_system == 'Windows'" }, 42 | ] 43 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 44 | wheels = [ 45 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 46 | ] 47 | 48 | [[package]] 49 | name = "colorama" 50 | version = "0.4.6" 51 | source = { registry = "https://pypi.org/simple" } 52 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 55 | ] 56 | 57 | [[package]] 58 | name = "h11" 59 | version = "0.14.0" 60 | source = { registry = "https://pypi.org/simple" } 61 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 62 | wheels = [ 63 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 64 | ] 65 | 66 | [[package]] 67 | name = "httpcore" 68 | version = "1.0.7" 69 | source = { registry = "https://pypi.org/simple" } 70 | dependencies = [ 71 | { name = "certifi" }, 72 | { name = "h11" }, 73 | ] 74 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 75 | wheels = [ 76 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 77 | ] 78 | 79 | [[package]] 80 | name = "httpx" 81 | version = "0.28.1" 82 | source = { registry = "https://pypi.org/simple" } 83 | dependencies = [ 84 | { name = "anyio" }, 85 | { name = "certifi" }, 86 | { name = "httpcore" }, 87 | { name = "idna" }, 88 | ] 89 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 92 | ] 93 | 94 | [[package]] 95 | name = "httpx-sse" 96 | version = "0.4.0" 97 | source = { registry = "https://pypi.org/simple" } 98 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 99 | wheels = [ 100 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 101 | ] 102 | 103 | [[package]] 104 | name = "idna" 105 | version = "3.10" 106 | source = { registry = "https://pypi.org/simple" } 107 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 108 | wheels = [ 109 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 110 | ] 111 | 112 | [[package]] 113 | name = "mcp" 114 | version = "1.2.0" 115 | source = { registry = "https://pypi.org/simple" } 116 | dependencies = [ 117 | { name = "anyio" }, 118 | { name = "httpx" }, 119 | { name = "httpx-sse" }, 120 | { name = "pydantic" }, 121 | { name = "pydantic-settings" }, 122 | { name = "sse-starlette" }, 123 | { name = "starlette" }, 124 | { name = "uvicorn" }, 125 | ] 126 | sdist = { url = "https://files.pythonhosted.org/packages/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } 127 | wheels = [ 128 | { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, 129 | ] 130 | 131 | [[package]] 132 | name = "mssql-mcp-server" 133 | version = "0.1.0" 134 | source = { editable = "." } 135 | dependencies = [ 136 | { name = "mcp" }, 137 | { name = "pymssql" }, 138 | ] 139 | 140 | [package.metadata] 141 | requires-dist = [ 142 | { name = "mcp", specifier = ">=1.0.0" }, 143 | { name = "pymssql", specifier = ">=2.2.8" }, 144 | ] 145 | 146 | [[package]] 147 | name = "pydantic" 148 | version = "2.10.4" 149 | source = { registry = "https://pypi.org/simple" } 150 | dependencies = [ 151 | { name = "annotated-types" }, 152 | { name = "pydantic-core" }, 153 | { name = "typing-extensions" }, 154 | ] 155 | sdist = { url = "https://files.pythonhosted.org/packages/70/7e/fb60e6fee04d0ef8f15e4e01ff187a196fa976eb0f0ab524af4599e5754c/pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06", size = 762094 } 156 | wheels = [ 157 | { url = "https://files.pythonhosted.org/packages/f3/26/3e1bbe954fde7ee22a6e7d31582c642aad9e84ffe4b5fb61e63b87cd326f/pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d", size = 431765 }, 158 | ] 159 | 160 | [[package]] 161 | name = "pydantic-core" 162 | version = "2.27.2" 163 | source = { registry = "https://pypi.org/simple" } 164 | dependencies = [ 165 | { name = "typing-extensions" }, 166 | ] 167 | sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } 168 | wheels = [ 169 | { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, 170 | { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, 171 | { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, 172 | { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, 173 | { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, 174 | { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, 175 | { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, 176 | { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, 177 | { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, 178 | { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, 179 | { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, 180 | { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, 181 | { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, 182 | { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, 183 | { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, 184 | { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, 185 | { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, 186 | { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, 187 | { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, 188 | { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, 189 | { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, 190 | { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, 191 | { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, 192 | { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, 193 | { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, 194 | { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, 195 | { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, 196 | { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, 197 | { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, 198 | { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, 199 | { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, 200 | { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, 201 | { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, 202 | { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, 203 | { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, 204 | { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, 205 | { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, 206 | { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, 207 | { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, 208 | { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, 209 | { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, 210 | { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, 211 | ] 212 | 213 | [[package]] 214 | name = "pydantic-settings" 215 | version = "2.7.1" 216 | source = { registry = "https://pypi.org/simple" } 217 | dependencies = [ 218 | { name = "pydantic" }, 219 | { name = "python-dotenv" }, 220 | ] 221 | sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 } 222 | wheels = [ 223 | { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 }, 224 | ] 225 | 226 | [[package]] 227 | name = "pymssql" 228 | version = "2.3.2" 229 | source = { registry = "https://pypi.org/simple" } 230 | sdist = { url = "https://files.pythonhosted.org/packages/81/c8/2ce5b171581c2e4d5d9726aaa805eb01febc7ed70a3bf686e1e0f5501b07/pymssql-2.3.2.tar.gz", hash = "sha256:18089641b687be1ebd0f64f0d1ff977478a397ffa1af372bdf10dbec29cf6d2e", size = 184760 } 231 | wheels = [ 232 | { url = "https://files.pythonhosted.org/packages/31/0d/a919acf75a26a5c5dabceb11b4f7446d7860a761ef68bdce3cd1055c9d25/pymssql-2.3.2-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:2a44a0898dacba4e25cac8778d0ed112e297883fe862204e447081888da78dc4", size = 3070057 }, 233 | { url = "https://files.pythonhosted.org/packages/e8/e0/3a87b214403c361a19bd6c7d8462a6f3a1e87661909fc326b8f5f0efd9f8/pymssql-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9737c06b13ca2012b9900185fa3af72a37941c532da2e6373dd7c9ab16abddf", size = 4044744 }, 234 | { url = "https://files.pythonhosted.org/packages/1b/f0/0359b8a371723d8e3a9255755e42fcb3ab32700a4a14b3121dbc438ad39f/pymssql-2.3.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0831c5c95aab0b9aba5142dc97e28f59c4130e1c34ffc13ecbfdd4d2fe45b8a0", size = 4032859 }, 235 | { url = "https://files.pythonhosted.org/packages/06/d6/3499b98a591bf713deed6f48b1b3b3e80c008b4ed1760d6f9c07f7824772/pymssql-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae02cc1594f0addd748bf5ac1ccc7a73c03846ada9c553663c381b242b586606", size = 4391018 }, 236 | { url = "https://files.pythonhosted.org/packages/9f/cb/d8aadb2628917b2fc386446f871dc32124c5029c9e48f6dea4d887dba70c/pymssql-2.3.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1c99dba4bf5b1ce10657e9e2885f18ba9179190251b63d1498e7d6d72e64f1ce", size = 4788674 }, 237 | { url = "https://files.pythonhosted.org/packages/2f/a0/d80b9ad5807f5a14e249f011a6d24f16fa6ef96bd6e643d9b677d74d90a0/pymssql-2.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9e3d6fada7fbe7a5f5fafc420673f777bab3f399c78fa44e29de6a8cbc36e515", size = 4123868 }, 238 | { url = "https://files.pythonhosted.org/packages/b9/87/5247858d1a7d03634c2082679c1a4fe40775e226fb3fc70c855851fe9938/pymssql-2.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5904d78e61668ec89761d3ae01efd4b42b31d820f612929f449e93cd23ba3c54", size = 4157236 }, 239 | { url = "https://files.pythonhosted.org/packages/3d/29/07da1e426b9627a870e762ec2d1b7f5fc144d4c201a312cec79633486cb0/pymssql-2.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9361593a89c9162fc631baf648a87e2666373382d9d54aacfb19edab9ceb2007", size = 4629804 }, 240 | { url = "https://files.pythonhosted.org/packages/36/9b/1ced1ab60e5b9e025aab65bede8f05595e1c763db1decd20c093f8267176/pymssql-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0768d90f96ae3267d7561d3bcfe94dd671d107489e870388b12570c3debbc552", size = 4843590 }, 241 | { url = "https://files.pythonhosted.org/packages/a4/9e/94af63f23becb5b411eba30d2090f17b8455c91166209e3c672d3199e859/pymssql-2.3.2-cp311-cp311-win32.whl", hash = "sha256:97fbd8491ad3ece0adcb321acec6db48b8fe37bc74af4c91bb657d4d9347d634", size = 1319041 }, 242 | { url = "https://files.pythonhosted.org/packages/65/f8/9336690fb988f7a848aaafd0b1df9aff9e16b7c24be1da7fc27e64e0b30c/pymssql-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:79cdc3ed1da3129ba56232127db86279728c4328595e2532ed4d0da6379a5c72", size = 2005840 }, 243 | { url = "https://files.pythonhosted.org/packages/03/b4/d9b30b565cf8af6d3f0e90802694860ff2e1c269d444be6ca24c4cfd9761/pymssql-2.3.2-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:235c230e56d8c8c5f289e665c538f31d967fec302d05ad269dcd64fa9d6eb3b7", size = 3042870 }, 244 | { url = "https://files.pythonhosted.org/packages/76/da/be4296cf0b4fd8b4f1a082cba2b8d08d7e730e98b8f0be62c84db891796f/pymssql-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bc33ed9af6d8ebea2d49144cd2317b7ae1105dd51dddfd46982c90c8f0cf6ab", size = 3987883 }, 245 | { url = "https://files.pythonhosted.org/packages/9e/80/ae1a77e5de1ca0a9f0a1ff5d9b60dc9c270e3afa6932302e459bd529aadc/pymssql-2.3.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:793a93da1521fa66bf02b3b873065e22bf14bda5570e005ce3d5fae0776d7b92", size = 3961886 }, 246 | { url = "https://files.pythonhosted.org/packages/38/d3/28e827a01234853fcfbb71703a5dcee490988eb5d1ff8859ac9fcc6db38c/pymssql-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b156b15165f7a0bbb392a124d8e2d678145c93e5bfcfef3b637e4d87eadcc85b", size = 4350551 }, 247 | { url = "https://files.pythonhosted.org/packages/30/53/626d5f203d3d05e6af5cfd1c611def622abb815ba7315c766c4faefd569d/pymssql-2.3.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f2b1da4e68d618c7972e583ae19f386ae620258acb61564e8067c203f27cd769", size = 4745384 }, 248 | { url = "https://files.pythonhosted.org/packages/67/ec/ff4d831bd250b2b5491c7f85abf04ce2c5613cd955e1855957b98fd72b89/pymssql-2.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2f4093b95f1f3a1232687fc92f652aaf675eb423db8549c16d146b91ac2f0eba", size = 4045126 }, 249 | { url = "https://files.pythonhosted.org/packages/85/ed/79ec7edbd5a99e445d85a46b48ea71ae9a920c9e92b743318446f4d4dffb/pymssql-2.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cc13c2e0f1b8efc3a46941de9db768fa59937b5a54081ec0cb0ff0da17d1fff3", size = 4107959 }, 250 | { url = "https://files.pythonhosted.org/packages/71/27/aff4b90fcfdfb3227f881d9ca6665139adbf1c397106e0f588493156e449/pymssql-2.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6019d2939963112662288704f608f31634038bffcfd5cad1bc79cb167edb3cc1", size = 4566181 }, 251 | { url = "https://files.pythonhosted.org/packages/9b/eb/376e2ae6ba7c7632137b9f46318573d0a988fc32184aea68eee64dc78d7a/pymssql-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41d09e1b2534229b288c37b88c1de3d964317af2c7eec58bfb97e01d679eba27", size = 4787575 }, 252 | { url = "https://files.pythonhosted.org/packages/6e/5b/fa906b132431009174bb966c7b7ce0da3dbd9343dc6e1ed6c448b22a4291/pymssql-2.3.2-cp312-cp312-win32.whl", hash = "sha256:b16d5880f7028442d6c49c94801ce9bff3af8af0fbda7c6039febb936714aed5", size = 1306859 }, 253 | { url = "https://files.pythonhosted.org/packages/11/2e/be51090e0c1c99b9259d06d2e3533c0e3681fd95203fc50040e6c18685a5/pymssql-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:a3f9e7eb813dfeab6d01bf6474049bb76b0521235159d3e969ec82df384eac49", size = 1990007 }, 254 | { url = "https://files.pythonhosted.org/packages/e0/26/f90c0251c0452fb6a80c44a7d7eb9b1e63e1657098659364ec81cb9cbb87/pymssql-2.3.2-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:f282e701dca155b3e7f1644d7e3b60c201ca5f3be8045bce34042d3c737d63ee", size = 3031958 }, 255 | { url = "https://files.pythonhosted.org/packages/ea/8d/8146de09a00a3c1737c1f1feb83a10519a406da045b3e7f5ad315d2266fd/pymssql-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1791f4627c42fe2d2833c884d036b0c5c8cf628f2cdfa3536191c217acf729e", size = 3981704 }, 256 | { url = "https://files.pythonhosted.org/packages/97/75/b1e7586c73e63f35664cf4dcf8df79d18892a3a57db3e93039443fb5a568/pymssql-2.3.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3870085a49e5332bc67ecb24f217c586977d5352eb51598244fc7bc278eee3e1", size = 3964863 }, 257 | { url = "https://files.pythonhosted.org/packages/40/5c/a1e6bbb17c5a606eeba78da8f13784c5afa6e614c9a44348a95c229fbb0e/pymssql-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1afda7b7022eff9451bd83e3f64c450a1a8cdff4ba8b8e399866dcd2cb861a1e", size = 4346193 }, 258 | { url = "https://files.pythonhosted.org/packages/ca/5f/ec35ac1efa66172c626a0e86cc1520d2964b415fae6f2a7a818ef1d98fcc/pymssql-2.3.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b0c2b11aca16617cacaf385fb94134e73ba0216a924f9b85778cc7e3d3713361", size = 4743947 }, 259 | { url = "https://files.pythonhosted.org/packages/1c/fa/9e1d88e2f025ce8d389f861bd962c0558ee23bc1b6d18981a967b6b51e6d/pymssql-2.3.2-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:2568944db3888996e161b40ad06c1b9e0fbb6cfcb38279a3abb98ece7a8e1c4a", size = 4047878 }, 260 | { url = "https://files.pythonhosted.org/packages/f5/2a/7ad8a39d8ff79a8f7ee7fc5a9c43f22cd365aff3f296b20a702c164eebb6/pymssql-2.3.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ee8ee2c7c227c413ad9b88ddba1cb6a25e28c217ae73ecac1c7a6b8c29003604", size = 4109700 }, 261 | { url = "https://files.pythonhosted.org/packages/b6/94/eed7fff479be51827e03c2bfcffda73dfe4e0d72c4c8144425aa63daede0/pymssql-2.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8cd806380d362d4cef2d925a6baee6a4b2b151a92cac2cab5c4bfabed4be4849", size = 4565816 }, 262 | { url = "https://files.pythonhosted.org/packages/f1/a1/f99f37547126981a351e0c8854f35b7d984238c68af54ff8863ea2d3644b/pymssql-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ef0d29c705db552f9e75230f946b0ca9db0db903c5c9ee79ce8b88ad25ea9670", size = 4786896 }, 263 | { url = "https://files.pythonhosted.org/packages/24/4f/93438cd488497f1c089d077380c3bc9a7adf98666fa01d7a380861440965/pymssql-2.3.2-cp313-cp313-win32.whl", hash = "sha256:1037053e6c74d6fe14c428cc942968b4e4bf06854706a83fe8e822e475e3f107", size = 1306239 }, 264 | { url = "https://files.pythonhosted.org/packages/ad/b9/6782fee30a1bb699aa023e132ca85d137e20466ef9fe562656a1e3dec25b/pymssql-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:148b7714fff5a5b7ce038e92b02dd9bf68fe442c181a3aae32148e7b13f6db95", size = 1988634 }, 265 | ] 266 | 267 | [[package]] 268 | name = "python-dotenv" 269 | version = "1.0.1" 270 | source = { registry = "https://pypi.org/simple" } 271 | sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } 272 | wheels = [ 273 | { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, 274 | ] 275 | 276 | [[package]] 277 | name = "sniffio" 278 | version = "1.3.1" 279 | source = { registry = "https://pypi.org/simple" } 280 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 281 | wheels = [ 282 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 283 | ] 284 | 285 | [[package]] 286 | name = "sse-starlette" 287 | version = "2.2.1" 288 | source = { registry = "https://pypi.org/simple" } 289 | dependencies = [ 290 | { name = "anyio" }, 291 | { name = "starlette" }, 292 | ] 293 | sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } 294 | wheels = [ 295 | { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, 296 | ] 297 | 298 | [[package]] 299 | name = "starlette" 300 | version = "0.45.2" 301 | source = { registry = "https://pypi.org/simple" } 302 | dependencies = [ 303 | { name = "anyio" }, 304 | ] 305 | sdist = { url = "https://files.pythonhosted.org/packages/90/4f/e1c9f4ec3dae67a94c9285ed275355d5f7cf0f3a5c34538c8ae5412af550/starlette-0.45.2.tar.gz", hash = "sha256:bba1831d15ae5212b22feab2f218bab6ed3cd0fc2dc1d4442443bb1ee52260e0", size = 2574026 } 306 | wheels = [ 307 | { url = "https://files.pythonhosted.org/packages/aa/ab/fe4f57c83620b39dfc9e7687ebad59129ff05170b99422105019d9a65eec/starlette-0.45.2-py3-none-any.whl", hash = "sha256:4daec3356fb0cb1e723a5235e5beaf375d2259af27532958e2d79df549dad9da", size = 71505 }, 308 | ] 309 | 310 | [[package]] 311 | name = "typing-extensions" 312 | version = "4.12.2" 313 | source = { registry = "https://pypi.org/simple" } 314 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 315 | wheels = [ 316 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 317 | ] 318 | 319 | [[package]] 320 | name = "uvicorn" 321 | version = "0.34.0" 322 | source = { registry = "https://pypi.org/simple" } 323 | dependencies = [ 324 | { name = "click" }, 325 | { name = "h11" }, 326 | ] 327 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 328 | wheels = [ 329 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 330 | ] 331 | --------------------------------------------------------------------------------