commit 20f96c0f308ef1e020df4484d09bc8670ab8e5b6 Author: OwusuBlessing Date: Thu Sep 11 23:13:58 2025 +0100 initial mcp server setup diff --git a/.env b/.env new file mode 100644 index 0000000..113dd37 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +OPENAI_API_KEY=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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..104a94a --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Ignore virtual environment +venv/ +groq_keys.json +cerebras_keys.json +# Ignore Python cache files +__pycache__/ + +# Ignore Jupyter Notebook checkpoints +.ipynb_checkpoints/ + +# Ignore log files +*.log +test.py + +# Ignore data directory +data/ + +# Ignore all .docx and .pdf files +*.docx +*.pdf +test_keys.py + +# Ignore all files in specs/ directory +specs/* +chronobid/* + +# Except for scp_engr_doc.json in specs/ directory +!specs/scp_engr_doc.json +!specs/Aienergy.discipline.json +!specs/Aienergy.discipline.criteria.json + +db.sqlite3 +services/lab.html +env.json + +api.json + +.DS_Store +_venv +testing_grounds.py +claude_messages.json +.env.production + +docs/flask_apispec diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/DEV_README.md b/DEV_README.md new file mode 100644 index 0000000..53d77a5 --- /dev/null +++ b/DEV_README.md @@ -0,0 +1,97 @@ +# MCP Development Setup + +This document explains how to use the MCP development server with different transport methods. + +## Quick Start with MCP Dev Command + +The easiest way to run the MCP server in development mode is using the dedicated dev file: + +```bash +mcp dev dev_run.py +``` + +This will: +- ✅ Automatically start the server with SSE transport +- ✅ Run on port 3000 (standard dev port) +- ✅ Show the server URL: `http://0.0.0.0:3000/sse` +- ✅ Auto-discover and register tools, prompts, and resources + +## Alternative Development Methods + +### Using the Main Server Script + +```bash +# Development mode (SSE on port 3000) +python run_mcp_server.py --dev + +# Custom development setup +python run_mcp_server.py --transport sse --port 8080 +``` + +### Manual Server Control + +```bash +# Default stdio transport +python run_mcp_server.py + +# SSE transport +python run_mcp_server.py --transport sse + +# Custom port +python run_mcp_server.py --transport sse --port 8080 + +# Streamable HTTP +python run_mcp_server.py --transport streamable-http --port 9000 +``` + +## Transport Methods + +### 1. STDIO Transport (Default) +- **Use case**: Command-line tools, testing +- **Port**: Not applicable (uses stdio) +- **Command**: `python run_mcp_server.py --transport stdio` + +### 2. SSE Transport +- **Use case**: Web applications, development +- **Default port**: 8050 (3000 in dev mode) +- **Command**: `python run_mcp_server.py --transport sse --port 8080` + +### 3. Streamable HTTP Transport +- **Use case**: Production HTTP applications +- **Default port**: 8050 +- **Command**: `python run_mcp_server.py --transport streamable-http --port 9000` + +## MCP Dev Command Usage + +The `dev_run.py` file is optimized for the MCP dev command: + +1. **Automatic Discovery**: MCP dev finds the server object automatically +2. **Standard Port**: Uses port 3000 (industry standard for dev) +3. **SSE Transport**: Optimized for web development +4. **Clean Output**: Shows clear status messages and URLs + +## Server Features + +The MCP server automatically: +- 🔍 Discovers tools from `src/mcp_template/tools/` +- 📝 Discovers prompts from `src/mcp_template/server/prompts/` +- 📁 Discovers resources from `src/mcp_template/server/resources/` +- 🚀 Registers all components with the MCP server +- 🌐 Provides appropriate transport endpoints + +## Troubleshooting + +### MCP Dev Command Issues +- Make sure `dev_run.py` is in the project root +- Ensure the MCP CLI is properly installed +- Check that the `mcp` object is properly exposed + +### Port Conflicts +- Change ports using `--port` parameter +- Common dev ports: 3000, 8080, 9000 +- Check for running processes: `lsof -i :PORT` + +### Import Errors +- Ensure you're running from the project root +- Check that all dependencies are installed +- Verify Python path includes the `src` directory diff --git a/MCP_CLIENT_README.md b/MCP_CLIENT_README.md new file mode 100644 index 0000000..3fbbcae --- /dev/null +++ b/MCP_CLIENT_README.md @@ -0,0 +1,221 @@ +# MCP OpenAI Client with Flexible Transport + +A powerful Python client that integrates OpenAI models with MCP (Model Context Protocol) servers, supporting both SSE and stdio transport methods. + +## 🌟 Features + +- **Flexible Transport**: Choose between SSE and stdio transport methods +- **OpenAI Integration**: Seamlessly use GPT models with MCP tools +- **Interactive Mode**: Command-line interface for natural conversation +- **Tool Execution**: Automatic tool calling based on user queries +- **Resource Support**: Access to MCP resources and prompts +- **Error Handling**: Robust error handling and connection management + +## 🚀 Quick Start + +### Prerequisites + +1. **OpenAI API Key**: Set your OpenAI API key as an environment variable: + ```bash + export OPENAI_API_KEY="your-api-key-here" + ``` + +2. **MCP Server Running**: Make sure your MCP server is running: + ```bash + # For SSE transport (recommended) + python run_mcp_server.py --transport sse + + # For stdio transport + python run_mcp_server.py --transport stdio + ``` + +### Basic Usage + +#### Interactive Mode (Recommended) +```bash +# Default: SSE transport, GPT-4o model +python mcp_openai_client.py + +# Stdio transport +python mcp_openai_client.py --transport stdio + +# Different model +python mcp_openai_client.py --model gpt-3.5-turbo +``` + +#### Single Query Mode +```bash +# Process a single query and exit +python mcp_openai_client.py --query "Calculate 15 + 27" + +# With different transport +python mcp_openai_client.py --transport stdio --query "What's the square root of 144?" +``` + +#### Examples and Demos +```bash +# Run example scripts +python example_usage.py +``` + +## 📖 Usage Examples + +### SSE Transport (Default) +```bash +python mcp_openai_client.py +``` +This will connect to `http://localhost:8050/sse` by default. + +### Custom Server URL +```bash +python mcp_openai_client.py --server-url http://localhost:3000/sse +``` + +### Stdio Transport +```bash +python mcp_openai_client.py --transport stdio --server-script run_mcp_server.py +``` + +### Different OpenAI Models +```bash +# GPT-3.5 Turbo (faster, cheaper) +python mcp_openai_client.py --model gpt-3.5-turbo + +# GPT-4 (more capable) +python mcp_openai_client.py --model gpt-4 + +# GPT-4o (recommended default) +python mcp_openai_client.py --model gpt-4o +``` + +## 🔧 Available Tools + +The client automatically discovers and uses all available MCP tools. Current tools include: + +### Math Tools +- `add` - Add two numbers +- `subtract` - Subtract numbers +- `multiply` - Multiply numbers +- `divide` - Divide numbers +- `power` - Calculate power +- `square_root` - Calculate square root +- `calculate_bmi` - Calculate BMI + +### Text Tools +- `count_words` - Count words in text +- `search_text` - Search for patterns +- `replace_text` - Replace text patterns +- `to_uppercase` - Convert to uppercase +- `to_lowercase` - Convert to lowercase +- `text_length` - Get text length + +### System Tools +- `get_system_info` - Get system information +- `get_current_time` - Get current time +- `list_directory` - List directory contents +- `get_file_info` - Get file information +- `get_environment_variable` - Get environment variables + +### Web Tools +- `url_encode` - URL encode strings +- `url_decode` - URL decode strings +- `parse_url` - Parse URLs +- `validate_email` - Validate email addresses +- `extract_domain` - Extract domains + +### Utility Tools +- `generate_greeting` - Generate personalized greetings + +## 📋 Available Resources + +- `config://settings` - Server configuration +- `dynamic://greeting` - Dynamic greeting resource + +## 💬 Example Queries + +Try these example queries to see the system in action: + +1. **Math**: "What is 123 multiplied by 456?" +2. **Text**: "Count the words in this sentence and convert it to uppercase" +3. **System**: "What time is it right now?" +4. **File**: "List the contents of the current directory" +5. **Web**: "Validate if 'user@example.com' is a valid email address" +6. **Combined**: "Calculate the BMI for someone who is 5'10\" tall and weighs 160 pounds, then tell me what time it is" + +## 🏗️ Architecture + +The client follows this workflow: + +1. **Connection**: Connects to MCP server using chosen transport (SSE/stdio) +2. **Discovery**: Discovers available tools, prompts, and resources +3. **Query Processing**: Sends user query to OpenAI with tool descriptions +4. **Tool Execution**: If OpenAI requests tool use, executes tools via MCP +5. **Response**: Returns final response incorporating tool results + +## 🔧 Configuration + +### Environment Variables +- `OPENAI_API_KEY` - Your OpenAI API key (required) + +### Command Line Options +- `--transport` - Transport type: `sse` (default) or `stdio` +- `--model` - OpenAI model: `gpt-4o` (default), `gpt-4`, `gpt-3.5-turbo` +- `--server-url` - Server URL for SSE transport +- `--server-script` - Server script path for stdio transport +- `--query` - Single query to process +- `--verbose` - Enable verbose output + +## 🚨 Troubleshooting + +### Connection Issues +- **SSE Connection Failed**: Make sure the MCP server is running with SSE transport +- **Stdio Connection Failed**: Check that the server script path is correct +- **Port Issues**: Ensure the server is running on the expected port (8050) + +### API Key Issues +- **Missing API Key**: Set the `OPENAI_API_KEY` environment variable +- **Invalid API Key**: Check that your OpenAI API key is valid and has credits + +### Tool Execution Issues +- **Tool Not Found**: The requested tool might not be available on the server +- **Tool Execution Failed**: Check the server logs for detailed error information + +## 📝 Development + +### Adding New Tools +1. Add your tool to the appropriate tool file in `src/mcp_template/tools/` +2. Follow the existing pattern with proper error handling +3. Test the tool individually before integration + +### Modifying Transport Logic +The client supports both SSE and stdio transports. To add new transports: +1. Add the transport type to the `TransportType` enum +2. Implement the connection method in `MCPOpenAIClient` +3. Update the `connect_to_server` method + +## 📄 License + +This project is part of the MCP Template system. See the main README for licensing information. + +--- + +## 🎯 Next Steps + +1. **Start the MCP Server**: + ```bash + python run_mcp_server.py --transport sse + ``` + +2. **Set your OpenAI API Key**: + ```bash + export OPENAI_API_KEY="your-key-here" + ``` + +3. **Run the Client**: + ```bash + python mcp_openai_client.py + ``` + +4. **Ask Questions**: Try queries like "Calculate 25 * 16" or "What tools do you have available?" + +Enjoy using your MCP-powered OpenAI assistant! 🤖✨ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e83e36 --- /dev/null +++ b/README.md @@ -0,0 +1,591 @@ +# MCP Template Server & LLM Client + +A comprehensive Python implementation of the **Model Context Protocol (MCP)** with multi-provider LLM support, featuring modular tools, prompts, and resources. This project provides a production-ready MCP server and flexible client that supports OpenAI, Claude, and Grok models. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Introduction](#introduction) +- [Architecture & Code Structure](#architecture--code-structure) +- [Tools](#tools) +- [Prompts](#prompts) +- [Resources](#resources) +- [Test Cases](#test-cases) +- [Configuration](#configuration) +- [Running the System](#running-the-system) +- [Development](#development) +- [API Reference](#api-reference) +- [Contributing](#contributing) + +## Quick Start + +### Basic Usage (5 minutes) + +```bash +# 1. Install dependencies +pip install -r requirements.txt + +# 2. Set your API key (choose your provider) +export OPENAI_API_KEY="your-openai-key" +# OR +export ANTHROPIC_API_KEY="your-claude-key" +# OR +export GROK_API_KEY="your-grok-key" + +# 3. Start the MCP server +python run_mcp_server.py --transport sse + +# 4. Run the client (in another terminal) +python mcp_llm_client.py --provider openai --query "Calculate 25 squared" +``` + +### Output +``` +Connected to MCP server using SSE transport +Processing query: 'Calculate 25 squared' +Calling openai/gpt-4o with 24 available tools... +Assistant wants to use 1 tool(s) + 1. Calling tool: power + Arguments: {'kwargs': '{"base": 25, "exponent": 2}'} + Result: 625 +Getting final response from openai/gpt-4o... +Final answer: 25 squared is 625. +``` + +## Introduction + +### What is MCP? + +**MCP (Model Context Protocol)** is an open standard for connecting AI applications to external systems and data sources. It enables AI models to: + +- **Execute tools** (calculations, API calls, system commands) +- **Access resources** (files, databases, configurations) +- **Use prompts** (reusable conversation templates) + +### Getting Started with MCP + +For a basic introduction to MCP concepts, see the [`intro_test/`](./intro_test/) directory, which contains: + +- **[Complete MCP Guide](./intro_test/INTRO_README.md)** - Comprehensive introduction to MCP concepts +- **Basic Server & Client Examples** - Simple implementations using FastMCP +- **Transport Protocol Examples** - SSE and STDIO transport implementations +- **OpenAI Integration** - Basic MCP-OpenAI client example + +> **New to MCP?** Start with [`intro_test/INTRO_README.md`](./intro_test/INTRO_README.md) for a complete beginner's guide. + +## Architecture & Code Structure + +``` +mcp_template/ +├── src/mcp_template/ +│ ├── core/ # Core MCP types and interfaces +│ ├── llm_client/ # Multi-provider LLM client factory +│ │ ├── base_client.py # Abstract base client +│ │ ├── openai_client.py # OpenAI implementation +│ │ ├── claude_client.py # Claude implementation +│ │ ├── grok_client.py # Grok implementation +│ │ └── client_factory.py # Factory for creating clients +│ ├── server/ +│ │ ├── modular_server.py # Main MCP server +│ │ ├── tools/ # Tool registration system +│ │ ├── prompts/ # Prompt management +│ │ └── resources/ # Resource management +│ └── tools/ # Tool implementations +│ ├── math_tools.py # Mathematical operations +│ ├── text_tools.py # Text processing +│ ├── system_tools.py # System utilities +│ └── web_tools.py # Web utilities +├── intro_test/ # Basic MCP examples +├── mcp_llm_client.py # Multi-provider MCP client +├── run_mcp_server.py # Server launcher +└── config.py # Configuration management +``` + +### Key Components + +- **`modular_server.py`** - Main MCP server with auto-discovery +- **`mcp_llm_client.py`** - Client supporting multiple AI providers +- **`client_factory.py`** - Factory pattern for LLM client creation +- **Tool Registry** - Automatic tool discovery and registration +- **Transport Layer** - SSE and STDIO transport implementations + +## Tools + +Tools are executable functions that AI models can invoke to perform actions. The system includes **24 built-in tools** across multiple categories. + +### Available Tool Categories + +#### Mathematical Tools (`math_tools.py`) +```python +# Available functions: add, subtract, multiply, divide, power, square_root, calculate_bmi +result = await add(a=10, b=5) # Returns: 15 +result = await power(base=5, exponent=3) # Returns: 125 +result = await square_root(number=144) # Returns: 12.0 +``` + +#### Text Processing Tools (`text_tools.py`) +```python +# Available functions: count_words, search_text, replace_text, to_uppercase, to_lowercase, text_length +word_count = await count_words(text="Hello world") # Returns: 2 +uppercase = await to_uppercase(text="hello") # Returns: "HELLO" +``` + +#### System Tools (`system_tools.py`) +```python +# Available functions: get_system_info, get_current_time, list_directory, get_file_info, get_environment_variable +sys_info = await get_system_info() # Returns system information +current_time = await get_current_time() # Returns: "2024-01-15 14:30:25" +``` + +#### Web Tools (`web_tools.py`) +```python +# Available functions: url_encode, url_decode, parse_url, validate_email, extract_domain +encoded = await url_encode(text="hello world") # Returns: "hello%20world" +is_valid = await validate_email(email="user@example.com") # Returns: True +``` + +### Creating Custom Tools + +#### Method 1: Using MCPTool Class +```python +from mcp_template.core.types import MCPTool + +async def custom_calculator(x: float, operation: str) -> float: + """Perform custom calculation""" + if operation == "double": + return x * 2 + elif operation == "half": + return x / 2 + return x + +tool = MCPTool( + name="custom_calculator", + description="Perform custom calculations", + input_schema={ + "type": "object", + "properties": { + "x": {"type": "number", "description": "Input number"}, + "operation": { + "type": "string", + "enum": ["double", "half"], + "description": "Operation to perform" + } + }, + "required": ["x", "operation"] + }, + handler=custom_calculator +) +``` + +#### Method 2: Using Tool Classes +```python +from mcp_template.tools.tool_registry import ServerToolRegistry + +class CustomTools: + @staticmethod + def get_tools(): + return [ + CustomTools._create_custom_tool(), + ] + + @staticmethod + def _create_custom_tool(): + async def my_tool(param1: str, param2: int = 0) -> str: + """Description of my tool""" + return f"Processed {param1} with {param2}" + + return MCPTool( + name="my_custom_tool", + description="Custom tool description", + input_schema={ + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter", "default": 0} + }, + "required": ["param1"] + }, + handler=my_tool + ) +``` + +#### Method 3: FastMCP Integration +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("MyServer") + +@mcp.tool() +async def my_fastmcp_tool(input: str) -> str: + """FastMCP tool with automatic schema generation""" + return f"Processed: {input}" + +# Tool is automatically registered with proper schema +``` + +### Tool Registration + +Tools are automatically discovered and registered: + +```python +# Automatic registration (recommended) +from mcp_template.server.modular_server import create_default_server + +server = create_default_server() +await server.initialize() # Automatically discovers and registers all tools + +# Manual registration +from mcp_template.server.tools.tool_registry import ServerToolRegistry + +registry = ServerToolRegistry() +registry.register_tools(your_custom_tools) +``` + +## Prompts + +Prompts are reusable conversation templates that help AI models perform specific tasks. + +### Available Prompts + +#### Greeting Prompt +```python +from mcp_template.server.prompts.greeting_prompt import GreetingPrompt + +prompt = GreetingPrompt() +result = await prompt.generate( + name="Alice", + style="friendly", + tone="warm", + element="compliment" +) +# Returns: Template with substituted variables +``` + +### Creating Custom Prompts + +```python +from mcp_template.server.prompts.base_prompt import BaseServerPrompt + +class CodeReviewPrompt(BaseServerPrompt): + def __init__(self): + super().__init__( + name="code_review", + description="Generate code review feedback", + template=""" + Please review the following {language} code for: + - Code quality and best practices + - Potential bugs or security issues + - Performance optimizations + - Readability improvements + + Code to review: + {code} + + Focus on: {focus_areas} + """, + arguments={ + "language": { + "type": "string", + "enum": ["python", "javascript", "java", "cpp"], + "description": "Programming language" + }, + "focus_areas": { + "type": "string", + "description": "Areas to focus the review on" + } + } + ) + + async def generate(self, code: str, language: str, focus_areas: str = "all") -> str: + return self._substitute_template( + code=code, + language=language, + focus_areas=focus_areas + ) +``` + +## Resources + +Resources represent data sources that AI models can access and read. + +### Available Resources + +#### Configuration Resource +```python +# URI: config://settings +# Returns: Server configuration in JSON format +{ + "server_name": "MCP Template Server", + "version": "1.0.0", + "features": ["tools", "prompts", "resources"], + "transport": {"supported": ["stdio", "sse"], "default": "stdio"}, + "tools_count": 24, + "prompts_count": 1, + "resources_count": 2 +} +``` + +#### Dynamic Greeting Resource +```python +# URI: dynamic://greeting +# Returns: Personalized greeting with current timestamp +``` + +### Creating Custom Resources + +```python +from mcp_template.server.resources.base_resource import BaseServerResource + +class DatabaseResource(BaseServerResource): + def __init__(self, db_path: str): + super().__init__( + uri=f"db://{db_path}", + name="Database Connection", + description=f"Access to database: {db_path}", + mime_type="application/json" + ) + self.db_path = db_path + + async def read(self, query: str = None, **kwargs) -> str: + """Execute database query and return results""" + # Implement database logic here + import json + + if query: + # Execute specific query + result = {"query": query, "result": "mock_data"} + else: + # Return database info + result = {"database": self.db_path, "status": "connected"} + + return json.dumps(result, indent=2) +``` + +## Test Cases + +Test cases should be written for each tool category to ensure proper functionality: + +### Mathematical Tools Tests +Should test basic arithmetic, power functions, square root calculations, and BMI calculations. + +### Text Tools Tests +Should test word counting, case conversion, text search, and text replacement functionality. + +### System Tools Tests +Should test system information retrieval, current time, and environment variable access. + +### Web Tools Tests +Should test URL encoding/decoding, email validation, and domain extraction. + +## Configuration + +### Environment Variables + +Create a `.env` file in the project root: + +```bash +# OpenAI (default provider) +OPENAI_API_KEY=sk-your-openai-key-here + +# Claude (alternative provider) +ANTHROPIC_API_KEY=sk-ant-your-claude-key-here + +# Grok (alternative provider) +GROK_API_KEY=xai-your-grok-key-here +``` + +### Server Configuration + +The server automatically discovers and registers: +- **Tools**: All classes in `src/mcp_template/tools/` +- **Prompts**: All classes in `src/mcp_template/server/prompts/` +- **Resources**: All classes in `src/mcp_template/server/resources/` + +## Running the System + +### Transport Methods + +#### 1. Server-Sent Events (SSE) - Recommended + +**Start Server:** +```bash +python run_mcp_server.py --transport sse --port 8050 +``` + +**Run Client:** +```bash +# Interactive mode +python mcp_llm_client.py --provider openai --model gpt-4o + +# Single query +python mcp_llm_client.py --provider openai --query "Calculate 25 + 30" + +# With custom parameters +python mcp_llm_client.py --provider openai --model gpt-4o --temperature 0.7 --max-tokens 1000 +``` + +#### 2. Standard Input/Output (STDIO) + +**Note:** STDIO transport doesn't require starting a separate server. The client automatically launches the server process. + +```bash +# The client will automatically start the server +python mcp_llm_client.py --transport stdio --provider openai --query "Calculate 10 * 5" +``` + +### Advanced Usage + +#### Multiple Providers +```bash +# OpenAI (default) +python mcp_llm_client.py --provider openai --model gpt-4o + +# Claude +python mcp_llm_client.py --provider claude --model claude-3-opus-20240229 + +# Grok +python mcp_llm_client.py --provider grok --model grok-1 +``` + +#### Custom Parameters +```bash +python mcp_llm_client.py \ + --provider openai \ + --model gpt-4o \ + --temperature 0.01 \ + --max-tokens 500 \ + --top-p 0.9 \ + --query "Explain quantum computing" +``` + +#### Interactive Mode +```bash +python mcp_llm_client.py --provider openai --temperature 0.7 + +# Now you can have natural conversations: +# Your query: Calculate 15 + 27 +# Response: 15 + 27 equals 42. +# +# Your query: What's the square root of 144? +# Response: The square root of 144 is 12. +# +# Your query: Count words in "Hello beautiful world" +# Response: The text contains 3 words. +``` + +### Docker Usage (Future) + +```bash +# Build the image +docker build -t mcp-template . + +# Run with environment variables +docker run -e OPENAI_API_KEY=your-key -p 8050:8050 mcp-template +``` + +## Development + +### Adding New Tools + +1. **Create tool file** in `src/mcp_template/tools/` +2. **Implement tool class** with `get_tools()` method +3. **Return MCPTool objects** with proper schemas +4. **Tools are auto-discovered** - no registration needed + +### Adding New Prompts + +1. **Create prompt class** in `src/mcp_template/server/prompts/` +2. **Extend BaseServerPrompt** +3. **Implement template and arguments** +4. **Prompts are auto-discovered** + +### Adding New Resources + +1. **Create resource class** in `src/mcp_template/server/resources/` +2. **Extend BaseServerResource** +3. **Implement read() method** +4. **Resources are auto-discovered** + +### Testing + +```bash +# Run all tests +python -m pytest tests/ + +# Run specific test file +python -m pytest tests/unit/test_tools.py + +# Run with coverage +python -m pytest --cov=src tests/ +``` + +## API Reference + +### MCPAIClient + +```python +class MCPAIClient: + def __init__( + self, + model: str = "gpt-4o", + transport: TransportType = TransportType.SSE, + provider: str = "openai", + temperature: float = 0.7, + max_tokens: int = 1000, + **kwargs + ) + + async def process_query(self, query: str) -> str + async def get_mcp_tools(self) -> List[Dict[str, Any]] + async def interactive_session(self) +``` + +### AIClientFactory + +```python +class AIClientFactory: + @staticmethod + def create_client(provider: str, model_name: str, **kwargs) -> BaseAIClient + + @staticmethod + def create_openai_client(model_name: str = "gpt-4o", **kwargs) -> OpenAIClient + + @staticmethod + def create_claude_client(model_name: str = "claude-3-opus-20240229", **kwargs) -> ClaudeClient + + @staticmethod + def create_grok_client(model_name: str = "grok-1", **kwargs) -> GrokClient +``` + +## Contributing + +1. **Fork the repository** +2. **Create feature branch**: `git checkout -b feature/your-feature` +3. **Add tests** for new functionality +4. **Ensure tests pass**: `python -m pytest` +5. **Submit pull request** + +### Code Standards + +- Use type hints for all function parameters and return values +- Follow PEP 8 style guidelines +- Add docstrings to all classes and methods +- Include unit tests for new features +- Update documentation for API changes + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +- **Documentation**: See [`intro_test/INTRO_README.md`](./intro_test/INTRO_README.md) for MCP basics +- **Issues**: Report bugs on GitHub Issues +- **Discussions**: Join discussions on GitHub Discussions +- **Email**: Contact maintainers for support + +--- + +**Built with using the Model Context Protocol (MCP)** + +*Transforming AI applications with standardized tool integration* diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/llm_client/base_client.py b/build/lib/llm_client/base_client.py new file mode 100644 index 0000000..0bd7b22 --- /dev/null +++ b/build/lib/llm_client/base_client.py @@ -0,0 +1,150 @@ +""" +Base AI Client with common MCP integration functionality +""" +import json +import os +import sys +from typing import Any, Dict, List, Optional +from abc import ABC + +# Add the project root to the path to import config +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +try: + from config import Config + CONFIG_AVAILABLE = True +except ImportError: + CONFIG_AVAILABLE = False + +from ..core.interfaces import IAIClient, IMCPClient + + +class BaseAIClient(IAIClient, ABC): + """Base class for AI clients with MCP integration""" + + def __init__(self, model_name: str, provider: str, api_key: Optional[str] = None, **kwargs): + self._model_name = model_name + self._provider = provider + self._client = None + self._initialized = False + self._extra_config = kwargs + + # Get API key from config if not provided + if api_key is None and CONFIG_AVAILABLE: + api_key = self._get_api_key_from_config() + + if not api_key: + raise ValueError(f"API key not provided and could not be loaded from config for provider: {provider}") + + self._api_key = api_key + + def _get_api_key_from_config(self) -> Optional[str]: + """Get API key from config based on provider""" + if not CONFIG_AVAILABLE: + return None + + provider_key_map = { + "openai": Config.OPENAI_API_KEY, + "claude": Config.CLAUDE_API_KEY, + "grok": Config.GROK_API_KEY + } + + return provider_key_map.get(self._provider) + + @property + def model_name(self) -> str: + """Get the AI model name""" + return self._model_name + + async def initialize(self) -> None: + """Initialize the AI client - to be implemented by subclasses""" + if self._initialized: + return + await self._initialize_client() + self._initialized = True + + async def _initialize_client(self) -> None: + """Initialize the specific AI client - to be implemented by subclasses""" + pass + + async def process_with_tools( + self, + query: str, + available_tools: List[Dict[str, Any]], + mcp_client: IMCPClient + ) -> str: + """Process a query with MCP tools using a common pattern""" + + # Format tools for the specific AI provider + formatted_tools = self._format_tools_for_provider(available_tools) + + # Create initial messages + messages = [{"role": "user", "content": query}] + + # Get AI response with tool calling + response = await self.chat_completion( + messages=messages, + tools=formatted_tools, + tool_choice="auto" + ) + + # Extract assistant message + assistant_message = response["choices"][0]["message"] + + # Check if tools were called + if "tool_calls" in assistant_message and assistant_message["tool_calls"]: + # Add assistant message to conversation + messages.append(assistant_message) + + # Process each tool call + for tool_call in assistant_message["tool_calls"]: + try: + # Extract tool call details + tool_name = tool_call["function"]["name"] + tool_args = json.loads(tool_call["function"]["arguments"]) + + # Call the tool via MCP client + tool_result = await mcp_client.call_tool(tool_name, tool_args) + + # Add tool response to conversation + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": str(tool_result), + }) + + except Exception as e: + # Handle tool call errors + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": f"Error calling tool: {str(e)}", + }) + + # Get final response from AI with tool results + final_response = await self.chat_completion( + messages=messages, + tools=formatted_tools, + tool_choice="none" # Don't allow more tool calls + ) + + return final_response["choices"][0]["message"]["content"] + + # No tools called, return direct response + return assistant_message["content"] + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for the specific AI provider - to be implemented by subclasses""" + return tools + + async def cleanup(self) -> None: + """Clean up resources""" + if self._client: + await self._cleanup_client() + self._initialized = False + + async def _cleanup_client(self) -> None: + """Clean up the specific AI client - to be implemented by subclasses""" + pass diff --git a/build/lib/mcp_template/__init__.py b/build/lib/mcp_template/__init__.py new file mode 100644 index 0000000..e2d634e --- /dev/null +++ b/build/lib/mcp_template/__init__.py @@ -0,0 +1,5 @@ +""" +MCP Template Source Package +""" + +__version__ = "0.1.0" diff --git a/build/lib/mcp_template/config/__init__.py b/build/lib/mcp_template/config/__init__.py new file mode 100644 index 0000000..bddd5cc --- /dev/null +++ b/build/lib/mcp_template/config/__init__.py @@ -0,0 +1,7 @@ +# Configuration management +from .config_manager import ConfigManager +from .server_config import ServerConfig +from .client_config import ClientConfig +from .transport_config import TransportConfig + +__all__ = ['ConfigManager', 'ServerConfig', 'ClientConfig', 'TransportConfig'] diff --git a/build/lib/mcp_template/config/client_config.py b/build/lib/mcp_template/config/client_config.py new file mode 100644 index 0000000..f87a259 --- /dev/null +++ b/build/lib/mcp_template/config/client_config.py @@ -0,0 +1,70 @@ +""" +Client Configuration +""" +from typing import Dict, Any, Optional +from dataclasses import dataclass + + +@dataclass +class ClientConfig: + """Configuration class for MCP clients""" + + provider: str = "openai" + model: str = "gpt-4o" + api_key: Optional[str] = None + + # Model parameters + temperature: float = 0.7 + max_tokens: int = 1000 + top_p: float = 1.0 + + # Connection settings + timeout: int = 30 + max_retries: int = 3 + retry_delay: float = 1.0 + + # MCP-specific settings + enable_tool_calling: bool = True + enable_resource_access: bool = True + enable_prompts: bool = True + + # Transport settings + transport_host: str = "localhost" + transport_port: int = 8050 + transport_endpoint: str = "/sse" + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'ClientConfig': + """Create ClientConfig from dictionary""" + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return self.__dict__.copy() + + def get_provider_config(self) -> Dict[str, Any]: + """Get provider-specific configuration""" + return { + "model_name": self.model, + "api_key": self.api_key, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "top_p": self.top_p, + "timeout": self.timeout, + "max_retries": self.max_retries, + "retry_delay": self.retry_delay, + } + + def validate(self) -> bool: + """Validate configuration""" + if not self.provider: + return False + if not self.model: + return False + if not self.api_key: + return False + if self.temperature < 0 or self.temperature > 2: + return False + if self.max_tokens < 1: + return False + return True diff --git a/build/lib/mcp_template/config/config_manager.py b/build/lib/mcp_template/config/config_manager.py new file mode 100644 index 0000000..dfbc121 --- /dev/null +++ b/build/lib/mcp_template/config/config_manager.py @@ -0,0 +1,133 @@ +""" +Centralized Configuration Manager +""" +import os +import json +from typing import Dict, Any, Optional +from pathlib import Path + +try: + from dotenv import load_dotenv + DOTENV_AVAILABLE = True +except ImportError: + DOTENV_AVAILABLE = False + + +class ConfigManager: + """Centralized configuration manager for MCP components""" + + def __init__(self, config_file: Optional[str] = None, env_file: Optional[str] = None): + self._config_file = config_file or "config.json" + self._env_file = env_file or ".env" + self._config: Dict[str, Any] = {} + self._loaded = False + + async def load_config(self) -> Dict[str, Any]: + """Load configuration from all sources""" + if self._loaded: + return self._config + + # Load environment variables + await self._load_env_vars() + + # Load JSON configuration file + await self._load_json_config() + + # Merge configurations + self._config = await self._merge_configs() + + self._loaded = True + return self._config + + async def _load_env_vars(self) -> None: + """Load environment variables""" + if DOTENV_AVAILABLE and Path(self._env_file).exists(): + load_dotenv(self._env_file) + + async def _load_json_config(self) -> Dict[str, Any]: + """Load JSON configuration file""" + config_path = Path(self._config_file) + + if config_path.exists(): + with open(config_path, 'r') as f: + return json.load(f) + + return {} + + async def _merge_configs(self) -> Dict[str, Any]: + """Merge all configuration sources""" + merged = {} + + # Start with JSON config as base + json_config = await self._load_json_config() + merged.update(json_config) + + # Override with environment variables + env_config = await self._get_env_config() + self._deep_update(merged, env_config) + + return merged + + async def _get_env_config(self) -> Dict[str, Any]: + """Get configuration from environment variables""" + env_config = {} + + # Server configuration + if os.getenv("MCP_SERVER_NAME"): + env_config.setdefault("server", {})["name"] = os.getenv("MCP_SERVER_NAME") + if os.getenv("MCP_SERVER_HOST"): + env_config.setdefault("server", {})["host"] = os.getenv("MCP_SERVER_HOST") + if os.getenv("MCP_SERVER_PORT"): + env_config.setdefault("server", {})["port"] = int(os.getenv("MCP_SERVER_PORT")) + if os.getenv("MCP_TRANSPORT"): + env_config.setdefault("server", {})["transport"] = os.getenv("MCP_TRANSPORT") + + # Client configuration + if os.getenv("MCP_AI_PROVIDER"): + env_config.setdefault("client", {})["provider"] = os.getenv("MCP_AI_PROVIDER") + if os.getenv("MCP_AI_MODEL"): + env_config.setdefault("client", {})["model"] = os.getenv("MCP_AI_MODEL") + + # API keys + for provider in ["OPENAI", "CLAUDE", "GROK"]: + api_key = os.getenv(f"{provider}_API_KEY") + if api_key: + env_config.setdefault("api_keys", {})[provider.lower()] = api_key + + return env_config + + def _deep_update(self, base_dict: Dict[str, Any], update_dict: Dict[str, Any]) -> None: + """Deep update dictionary""" + for key, value in update_dict.items(): + if isinstance(value, dict) and key in base_dict and isinstance(base_dict[key], dict): + self._deep_update(base_dict[key], value) + else: + base_dict[key] = value + + async def get_server_config(self) -> Dict[str, Any]: + """Get server-specific configuration""" + config = await self.load_config() + return config.get("server", {}) + + async def get_client_config(self) -> Dict[str, Any]: + """Get client-specific configuration""" + config = await self.load_config() + return config.get("client", {}) + + async def get_api_key(self, provider: str) -> Optional[str]: + """Get API key for a specific provider""" + config = await self.load_config() + api_keys = config.get("api_keys", {}) + return api_keys.get(provider.lower()) + + async def save_config(self, config: Dict[str, Any]) -> None: + """Save configuration to file""" + with open(self._config_file, 'w') as f: + json.dump(config, f, indent=2) + + async def update_config(self, updates: Dict[str, Any]) -> None: + """Update configuration and save""" + config = await self.load_config() + self._deep_update(config, updates) + await self.save_config(config) + self._loaded = False # Force reload on next access diff --git a/build/lib/mcp_template/config/server_config.py b/build/lib/mcp_template/config/server_config.py new file mode 100644 index 0000000..2e9aa1a --- /dev/null +++ b/build/lib/mcp_template/config/server_config.py @@ -0,0 +1,72 @@ +""" +Server Configuration +""" +from typing import Dict, Any, Optional +from dataclasses import dataclass + +from ..core.types import TransportType + + +@dataclass +class ServerConfig: + """Configuration class for MCP servers""" + + name: str = "MCP Server" + version: str = "1.0.0" + transport: TransportType = TransportType.STDIO + host: str = "0.0.0.0" + port: int = 8050 + stateless_http: bool = True + + # Tool configurations + enable_default_tools: bool = True + custom_tools: Optional[Dict[str, Any]] = None + + # Resource configurations + enable_file_resources: bool = False + resource_paths: Optional[list[str]] = None + + # Prompt configurations + enable_default_prompts: bool = False + custom_prompts: Optional[Dict[str, Any]] = None + + # Performance settings + max_concurrent_requests: int = 10 + request_timeout: int = 30 + + def __post_init__(self): + """Initialize optional fields""" + if self.custom_tools is None: + self.custom_tools = {} + if self.resource_paths is None: + self.resource_paths = [] + if self.custom_prompts is None: + self.custom_prompts = {} + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'ServerConfig': + """Create ServerConfig from dictionary""" + # Convert transport string to enum + if 'transport' in config_dict and isinstance(config_dict['transport'], str): + config_dict['transport'] = TransportType(config_dict['transport'].lower()) + + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + result = self.__dict__.copy() + result['transport'] = self.transport.value + return result + + def get_transport_config(self) -> Dict[str, Any]: + """Get transport-specific configuration""" + if self.transport == TransportType.SSE: + return { + "host": self.host, + "port": self.port, + "endpoint": "/sse", + } + elif self.transport == TransportType.STDIO: + return {} + else: + return {} diff --git a/build/lib/mcp_template/config/transport_config.py b/build/lib/mcp_template/config/transport_config.py new file mode 100644 index 0000000..0b71545 --- /dev/null +++ b/build/lib/mcp_template/config/transport_config.py @@ -0,0 +1,89 @@ +""" +Transport Configuration +""" +from typing import Dict, Any, Optional +from dataclasses import dataclass + +from ..core.types import TransportType + + +@dataclass +class TransportConfig: + """Configuration class for MCP transport""" + + transport_type: TransportType = TransportType.STDIO + + # SSE-specific settings + sse_host: str = "localhost" + sse_port: int = 8050 + sse_endpoint: str = "/sse" + sse_timeout: int = 30 + sse_reconnect_delay: float = 1.0 + sse_max_reconnects: int = 5 + + # STDIO-specific settings + stdio_command: Optional[str] = None + stdio_args: Optional[list[str]] = None + stdio_env: Optional[Dict[str, str]] = None + stdio_cwd: Optional[str] = None + + # General transport settings + buffer_size: int = 8192 + encoding: str = "utf-8" + enable_compression: bool = False + enable_ssl: bool = False + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'TransportConfig': + """Create TransportConfig from dictionary""" + # Convert transport string to enum + if 'transport_type' in config_dict and isinstance(config_dict['transport_type'], str): + config_dict['transport_type'] = TransportType(config_dict['transport_type'].lower()) + + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + result = self.__dict__.copy() + result['transport_type'] = self.transport_type.value + return result + + def get_sse_config(self) -> Dict[str, Any]: + """Get SSE-specific configuration""" + return { + "host": self.sse_host, + "port": self.sse_port, + "endpoint": self.sse_endpoint, + "timeout": self.sse_timeout, + "reconnect_delay": self.sse_reconnect_delay, + "max_reconnects": self.sse_max_reconnects, + } + + def get_stdio_config(self) -> Dict[str, Any]: + """Get STDIO-specific configuration""" + config = {} + if self.stdio_command: + config["command"] = self.stdio_command + if self.stdio_args: + config["args"] = self.stdio_args + if self.stdio_env: + config["env"] = self.stdio_env + if self.stdio_cwd: + config["cwd"] = self.stdio_cwd + return config + + def get_transport_config(self) -> Dict[str, Any]: + """Get transport configuration based on type""" + config = { + "buffer_size": self.buffer_size, + "encoding": self.encoding, + "enable_compression": self.enable_compression, + "enable_ssl": self.enable_ssl, + } + + if self.transport_type == TransportType.SSE: + config.update(self.get_sse_config()) + elif self.transport_type == TransportType.STDIO: + config.update(self.get_stdio_config()) + + return config diff --git a/build/lib/mcp_template/core/__init__.py b/build/lib/mcp_template/core/__init__.py new file mode 100644 index 0000000..dc7f94c --- /dev/null +++ b/build/lib/mcp_template/core/__init__.py @@ -0,0 +1,13 @@ +# Core MCP abstractions and base classes +from .types import MCPTool, MCPResource, MCPPrompt, MCPServerConfig +from .interfaces import IMCPServer, IMCPClient, IMCPTransport + +__all__ = [ + 'MCPTool', + 'MCPResource', + 'MCPPrompt', + 'MCPServerConfig', + 'IMCPServer', + 'IMCPClient', + 'IMCPTransport' +] diff --git a/build/lib/mcp_template/core/interfaces.py b/build/lib/mcp_template/core/interfaces.py new file mode 100644 index 0000000..d75cbff --- /dev/null +++ b/build/lib/mcp_template/core/interfaces.py @@ -0,0 +1,181 @@ +""" +Core MCP Interfaces and Abstract Base Classes +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Protocol +from contextlib import asynccontextmanager + +from .types import MCPTool, MCPResource, MCPPrompt, MCPServerConfig + + +class IMCPServer(ABC): + """Abstract base class for MCP servers""" + + @abstractmethod + async def initialize(self) -> None: + """Initialize the MCP server""" + pass + + @abstractmethod + async def register_tool(self, tool: MCPTool) -> None: + """Register a tool with the server""" + pass + + @abstractmethod + async def register_resource(self, resource: MCPResource) -> None: + """Register a resource with the server""" + pass + + @abstractmethod + async def register_prompt(self, prompt: MCPPrompt) -> None: + """Register a prompt with the server""" + pass + + @abstractmethod + async def list_tools(self) -> List[Dict[str, Any]]: + """List all available tools""" + pass + + @abstractmethod + async def list_resources(self) -> List[Dict[str, Any]]: + """List all available resources""" + pass + + @abstractmethod + async def list_prompts(self) -> List[Dict[str, Any]]: + """List all available prompts""" + pass + + @abstractmethod + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any: + """Call a tool by name with arguments""" + pass + + @abstractmethod + async def read_resource(self, uri: str) -> Any: + """Read a resource by URI""" + pass + + @abstractmethod + async def get_prompt(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> str: + """Get a prompt by name with optional arguments""" + pass + + @abstractmethod + async def start(self) -> None: + """Start the MCP server""" + pass + + @abstractmethod + async def stop(self) -> None: + """Stop the MCP server""" + pass + + +class IMCPClient(ABC): + """Abstract base class for MCP clients""" + + @abstractmethod + async def connect(self) -> None: + """Connect to MCP server""" + pass + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from MCP server""" + pass + + @abstractmethod + async def list_tools(self) -> List[Dict[str, Any]]: + """List available tools from server""" + pass + + @abstractmethod + async def list_resources(self) -> List[Dict[str, Any]]: + """List available resources from server""" + pass + + @abstractmethod + async def list_prompts(self) -> List[Dict[str, Any]]: + """List available prompts from server""" + pass + + @abstractmethod + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any: + """Call a tool on the server""" + pass + + @abstractmethod + async def read_resource(self, uri: str) -> Any: + """Read a resource from the server""" + pass + + @abstractmethod + async def get_prompt(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> str: + """Get a prompt from the server""" + pass + + +class IMCPTransport(ABC): + """Abstract base class for MCP transport mechanisms""" + + @abstractmethod + @asynccontextmanager + async def connect(self): + """Establish transport connection""" + pass + + @abstractmethod + async def send_message(self, message: Dict[str, Any]) -> None: + """Send a message through the transport""" + pass + + @abstractmethod + async def receive_message(self) -> Dict[str, Any]: + """Receive a message through the transport""" + pass + + @abstractmethod + async def close(self) -> None: + """Close the transport connection""" + pass + + +class IAIClient(ABC): + """Abstract base class for AI model clients""" + + @property + @abstractmethod + def model_name(self) -> str: + """Get the AI model name""" + pass + + @abstractmethod + async def initialize(self) -> None: + """Initialize the AI client""" + pass + + @abstractmethod + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform a chat completion with optional tools""" + pass + + @abstractmethod + async def process_with_tools( + self, + query: str, + available_tools: List[Dict[str, Any]], + mcp_client: IMCPClient + ) -> str: + """Process a query with MCP tools""" + pass + + @abstractmethod + async def cleanup(self) -> None: + """Clean up resources""" + pass diff --git a/build/lib/mcp_template/core/types.py b/build/lib/mcp_template/core/types.py new file mode 100644 index 0000000..84b0d2b --- /dev/null +++ b/build/lib/mcp_template/core/types.py @@ -0,0 +1,79 @@ +""" +Core MCP Types and Data Structures +""" +from typing import Any, Dict, List, Optional, Protocol, Union +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + + +class TransportType(Enum): + """Supported MCP transport types""" + SSE = "sse" + STDIO = "stdio" + + +@dataclass +class MCPTool: + """Represents an MCP tool that can be called by AI models""" + name: str + description: str + input_schema: Dict[str, Any] + handler: callable + + def __post_init__(self): + """Validate tool configuration""" + if not self.name or not self.description: + raise ValueError("Tool name and description are required") + + +@dataclass +class MCPResource: + """Represents an MCP resource that can be read by clients""" + uri: str + name: str + description: str + mime_type: str + content: Union[str, bytes] + + def __post_init__(self): + """Validate resource configuration""" + if not self.uri or not self.name: + raise ValueError("Resource URI and name are required") + + +@dataclass +class MCPPrompt: + """Represents an MCP prompt template""" + name: str + description: str + template: str + arguments: Optional[Dict[str, Any]] = None + + def __post_init__(self): + """Validate prompt configuration""" + if not self.name or not self.template: + raise ValueError("Prompt name and template are required") + + +@dataclass +class MCPServerConfig: + """Configuration for MCP server""" + name: str + version: str = "1.0.0" + transport: TransportType = TransportType.STDIO + host: str = "0.0.0.0" + port: int = 8050 + tools: List[MCPTool] = None + resources: List[MCPResource] = None + prompts: List[MCPPrompt] = None + stateless_http: bool = True + + def __post_init__(self): + """Initialize empty lists if not provided""" + if self.tools is None: + self.tools = [] + if self.resources is None: + self.resources = [] + if self.prompts is None: + self.prompts = [] diff --git a/build/lib/mcp_template/examples/__init__.py b/build/lib/mcp_template/examples/__init__.py new file mode 100644 index 0000000..f58010e --- /dev/null +++ b/build/lib/mcp_template/examples/__init__.py @@ -0,0 +1,6 @@ +# MCP Template Examples +from .server_examples import ServerExamples +from .client_examples import ClientExamples +from .integration_examples import IntegrationExamples + +__all__ = ['ServerExamples', 'ClientExamples', 'IntegrationExamples'] diff --git a/build/lib/mcp_template/examples/server_examples.py b/build/lib/mcp_template/examples/server_examples.py new file mode 100644 index 0000000..c64106e --- /dev/null +++ b/build/lib/mcp_template/examples/server_examples.py @@ -0,0 +1,174 @@ +""" +Server Examples showing how to use the modular MCP template +""" +import asyncio +from typing import List +from ..core.types import TransportType +from ..server.server_factory import MCPServerFactory +from ..tools.tool_registry import ToolRegistry +from ..resources.data_resources import DataResources + + +class ServerExamples: + """Examples of creating different types of MCP servers""" + + @staticmethod + async def create_math_server(): + """Create a server with only math tools""" + print("🧮 Creating Math Server...") + + # Use tool registry to get math tools + registry = ToolRegistry() + math_tools = registry.get_tools_by_category('math') + + server = MCPServerFactory.create_server( + name="Math Server", + transport=TransportType.STDIO, + tools=math_tools + ) + + print(f"✅ Math Server created with {len(math_tools)} tools") + return server + + @staticmethod + async def create_developer_server(): + """Create a comprehensive developer server""" + print("👨‍💻 Creating Developer Server...") + + registry = ToolRegistry() + tools = registry.get_tools_by_categories(['math', 'text', 'system']) + + # Add data resources + resources = DataResources.get_resources() + + server = MCPServerFactory.create_server( + name="Developer Server", + transport=TransportType.SSE, + host="localhost", + port=8050, + tools=tools, + resources=resources + ) + + print(f"✅ Developer Server created with {len(tools)} tools and {len(resources)} resources") + return server + + @staticmethod + async def create_business_server(): + """Create a business-focused server""" + print("💼 Creating Business Server...") + + registry = ToolRegistry() + + # Add custom business tools + async def calculate_roi(initial_investment: float, final_value: float) -> str: + """Calculate Return on Investment""" + if initial_investment <= 0: + raise ValueError("Initial investment must be positive") + roi = ((final_value - initial_investment) / initial_investment) * 100 + return ".2f" + + async def format_currency(amount: float, currency: str = "USD") -> str: + """Format amount as currency""" + return ",.2f" + + from ..core.types import MCPTool + business_tools = [ + MCPTool( + name="calculate_roi", + description="Calculate Return on Investment percentage", + input_schema={ + "type": "object", + "properties": { + "initial_investment": {"type": "number", "description": "Initial investment amount"}, + "final_value": {"type": "number", "description": "Final value amount"}, + }, + "required": ["initial_investment", "final_value"], + }, + handler=calculate_roi, + ), + MCPTool( + name="format_currency", + description="Format a number as currency", + input_schema={ + "type": "object", + "properties": { + "amount": {"type": "number", "description": "Amount to format"}, + "currency": {"type": "string", "description": "Currency code", "default": "USD"}, + }, + "required": ["amount"], + }, + handler=format_currency, + ), + ] + + # Add to registry + registry.add_custom_tools(business_tools) + + # Get business-relevant tools + all_tools = registry.get_tools_by_categories(['math', 'text']) + all_tools.extend(business_tools) + + server = MCPServerFactory.create_server( + name="Business Server", + transport=TransportType.STDIO, + tools=all_tools + ) + + print(f"✅ Business Server created with {len(all_tools)} tools") + return server + + @staticmethod + async def create_custom_server_with_config(): + """Create a server using configuration""" + print("⚙️ Creating Custom Server with Configuration...") + + config = { + "name": "Custom Config Server", + "transport": "sse", + "host": "localhost", + "port": 8080, + "tools": ["math", "text"], # Tool categories to include + "enable_resources": True, + } + + registry = ToolRegistry() + tools = registry.get_tools_by_categories(config["tools"]) + resources = DataResources.get_resources() if config.get("enable_resources") else [] + + server = MCPServerFactory.create_server( + name=config["name"], + transport=config["transport"], + host=config["host"], + port=config["port"], + tools=tools, + resources=resources + ) + + print(f"✅ Custom Server created with configuration") + print(f" - Tools: {len(tools)}") + print(f" - Resources: {len(resources)}") + print(f" - Transport: {config['transport']}") + return server + + @staticmethod + async def demo_server_lifecycle(): + """Demonstrate complete server lifecycle""" + print("🔄 Server Lifecycle Demo") + + # Create server + server = await ServerExamples.create_math_server() + + # List available tools + tools = await server.list_tools() + print(f"📋 Available tools: {[t['name'] for t in tools]}") + + # Test a tool + try: + result = await server.call_tool("add", {"a": 10, "b": 5}) + print(f"🧮 Tool test - add(10, 5) = {result}") + except Exception as e: + print(f"❌ Tool test failed: {e}") + + print("✅ Server lifecycle demo completed") + return server diff --git a/build/lib/mcp_template/llm_client/__init__.py b/build/lib/mcp_template/llm_client/__init__.py new file mode 100644 index 0000000..89a0471 --- /dev/null +++ b/build/lib/mcp_template/llm_client/__init__.py @@ -0,0 +1,14 @@ +# AI Client implementations +from .base_client import BaseAIClient +from .openai_client import OpenAIClient +from .claude_client import ClaudeClient +from .grok_client import GrokClient +from .client_factory import AIClientFactory + +__all__ = [ + 'BaseAIClient', + 'OpenAIClient', + 'ClaudeClient', + 'GrokClient', + 'AIClientFactory' +] diff --git a/build/lib/mcp_template/llm_client/base_client.py b/build/lib/mcp_template/llm_client/base_client.py new file mode 100644 index 0000000..0bd7b22 --- /dev/null +++ b/build/lib/mcp_template/llm_client/base_client.py @@ -0,0 +1,150 @@ +""" +Base AI Client with common MCP integration functionality +""" +import json +import os +import sys +from typing import Any, Dict, List, Optional +from abc import ABC + +# Add the project root to the path to import config +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +try: + from config import Config + CONFIG_AVAILABLE = True +except ImportError: + CONFIG_AVAILABLE = False + +from ..core.interfaces import IAIClient, IMCPClient + + +class BaseAIClient(IAIClient, ABC): + """Base class for AI clients with MCP integration""" + + def __init__(self, model_name: str, provider: str, api_key: Optional[str] = None, **kwargs): + self._model_name = model_name + self._provider = provider + self._client = None + self._initialized = False + self._extra_config = kwargs + + # Get API key from config if not provided + if api_key is None and CONFIG_AVAILABLE: + api_key = self._get_api_key_from_config() + + if not api_key: + raise ValueError(f"API key not provided and could not be loaded from config for provider: {provider}") + + self._api_key = api_key + + def _get_api_key_from_config(self) -> Optional[str]: + """Get API key from config based on provider""" + if not CONFIG_AVAILABLE: + return None + + provider_key_map = { + "openai": Config.OPENAI_API_KEY, + "claude": Config.CLAUDE_API_KEY, + "grok": Config.GROK_API_KEY + } + + return provider_key_map.get(self._provider) + + @property + def model_name(self) -> str: + """Get the AI model name""" + return self._model_name + + async def initialize(self) -> None: + """Initialize the AI client - to be implemented by subclasses""" + if self._initialized: + return + await self._initialize_client() + self._initialized = True + + async def _initialize_client(self) -> None: + """Initialize the specific AI client - to be implemented by subclasses""" + pass + + async def process_with_tools( + self, + query: str, + available_tools: List[Dict[str, Any]], + mcp_client: IMCPClient + ) -> str: + """Process a query with MCP tools using a common pattern""" + + # Format tools for the specific AI provider + formatted_tools = self._format_tools_for_provider(available_tools) + + # Create initial messages + messages = [{"role": "user", "content": query}] + + # Get AI response with tool calling + response = await self.chat_completion( + messages=messages, + tools=formatted_tools, + tool_choice="auto" + ) + + # Extract assistant message + assistant_message = response["choices"][0]["message"] + + # Check if tools were called + if "tool_calls" in assistant_message and assistant_message["tool_calls"]: + # Add assistant message to conversation + messages.append(assistant_message) + + # Process each tool call + for tool_call in assistant_message["tool_calls"]: + try: + # Extract tool call details + tool_name = tool_call["function"]["name"] + tool_args = json.loads(tool_call["function"]["arguments"]) + + # Call the tool via MCP client + tool_result = await mcp_client.call_tool(tool_name, tool_args) + + # Add tool response to conversation + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": str(tool_result), + }) + + except Exception as e: + # Handle tool call errors + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": f"Error calling tool: {str(e)}", + }) + + # Get final response from AI with tool results + final_response = await self.chat_completion( + messages=messages, + tools=formatted_tools, + tool_choice="none" # Don't allow more tool calls + ) + + return final_response["choices"][0]["message"]["content"] + + # No tools called, return direct response + return assistant_message["content"] + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for the specific AI provider - to be implemented by subclasses""" + return tools + + async def cleanup(self) -> None: + """Clean up resources""" + if self._client: + await self._cleanup_client() + self._initialized = False + + async def _cleanup_client(self) -> None: + """Clean up the specific AI client - to be implemented by subclasses""" + pass diff --git a/build/lib/mcp_template/llm_client/claude_client.py b/build/lib/mcp_template/llm_client/claude_client.py new file mode 100644 index 0000000..ce5cbb1 --- /dev/null +++ b/build/lib/mcp_template/llm_client/claude_client.py @@ -0,0 +1,56 @@ +""" +Claude Client Implementation (Placeholder) +""" +from typing import Any, Dict, List, Optional + +# Placeholder for Claude/Anthropic client +# This would need the actual Anthropic SDK when implemented + +from .base_client import BaseAIClient + + +class ClaudeClient(BaseAIClient): + """Claude client with MCP integration (Placeholder Implementation)""" + + def __init__( + self, + model_name: str = "claude-3-opus-20240229", + api_key: Optional[str] = None, + **kwargs + ): + # Note: This is a placeholder. You'll need to install the Anthropic SDK + # pip install anthropic + super().__init__(model_name, "claude", api_key, **kwargs) + + # Claude specific configuration + self._temperature = kwargs.get("temperature", 0.7) + self._max_tokens = kwargs.get("max_tokens", 1000) + + async def _initialize_client(self) -> None: + """Initialize the Claude client""" + # TODO: Implement with actual Anthropic SDK + # self._client = Anthropic(api_key=self._api_key) + raise NotImplementedError("Claude client not yet implemented. Install Anthropic SDK and implement.") + + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform Claude chat completion""" + if not self._initialized: + await self.initialize() + + # TODO: Implement Claude API call + raise NotImplementedError("Claude chat completion not yet implemented") + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for Claude's expected format""" + # TODO: Implement Claude tool formatting + return tools + + async def _cleanup_client(self) -> None: + """Clean up Claude client""" + # TODO: Implement cleanup + pass diff --git a/build/lib/mcp_template/llm_client/client_factory.py b/build/lib/mcp_template/llm_client/client_factory.py new file mode 100644 index 0000000..c12521c --- /dev/null +++ b/build/lib/mcp_template/llm_client/client_factory.py @@ -0,0 +1,104 @@ +""" +AI Client Factory for easy client creation and management +""" +import os +import sys +from typing import Optional, Dict, Any + +# Add the project root to the path to import config +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +try: + from config import Config + CONFIG_AVAILABLE = True +except ImportError: + CONFIG_AVAILABLE = False + +from .base_client import BaseAIClient +from .openai_client import OpenAIClient +from .claude_client import ClaudeClient +from .grok_client import GrokClient + + +class AIClientFactory: + """Factory class for creating AI clients with different providers""" + + @staticmethod + def create_client( + provider: str, + model_name: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs + ) -> BaseAIClient: + """Create an AI client for the specified provider""" + + # Set default model names if not provided + if model_name is None: + if provider.lower() == "openai": + model_name = "gpt-4o" + elif provider.lower() == "claude": + model_name = "claude-3-opus-20240229" + elif provider.lower() == "grok": + model_name = "grok-1" + + # Get API key from config if not provided + if api_key is None and CONFIG_AVAILABLE: + provider_key_map = { + "openai": Config.OPENAI_API_KEY, + "claude": Config.CLAUDE_API_KEY, + "grok": Config.GROK_API_KEY + } + api_key = provider_key_map.get(provider.lower()) + + if not api_key: + raise ValueError(f"API key not provided and could not be loaded from config for provider: {provider}") + + # Create the appropriate client + provider_lower = provider.lower() + if provider_lower == "openai": + return OpenAIClient(model_name, api_key, **kwargs) + elif provider_lower == "claude": + return ClaudeClient(model_name, api_key, **kwargs) + elif provider_lower == "grok": + return GrokClient(model_name, api_key, **kwargs) + else: + raise ValueError(f"Unsupported AI provider: {provider}") + + @staticmethod + def create_openai_client( + model_name: str = "gpt-4o", + api_key: Optional[str] = None, + **kwargs + ) -> OpenAIClient: + """Create an OpenAI client""" + return AIClientFactory.create_client("openai", model_name, api_key, **kwargs) + + @staticmethod + def create_claude_client( + model_name: str = "claude-3-opus-20240229", + api_key: Optional[str] = None, + **kwargs + ) -> ClaudeClient: + """Create a Claude client""" + return AIClientFactory.create_client("claude", model_name, api_key, **kwargs) + + @staticmethod + def create_grok_client( + model_name: str = "grok-1", + api_key: Optional[str] = None, + **kwargs + ) -> GrokClient: + """Create a Grok client""" + return AIClientFactory.create_client("grok", model_name, api_key, **kwargs) + + @staticmethod + def get_available_providers() -> list[str]: + """Get list of available AI providers""" + return ["openai", "claude", "grok"] + + @staticmethod + def validate_provider(provider: str) -> bool: + """Validate if a provider is supported""" + return provider.lower() in AIClientFactory.get_available_providers() diff --git a/build/lib/mcp_template/llm_client/grok_client.py b/build/lib/mcp_template/llm_client/grok_client.py new file mode 100644 index 0000000..0bb1968 --- /dev/null +++ b/build/lib/mcp_template/llm_client/grok_client.py @@ -0,0 +1,55 @@ +""" +Grok Client Implementation (Placeholder) +""" +from typing import Any, Dict, List, Optional + +# Placeholder for Grok/xAI client +# This would need the xAI SDK or direct API integration when implemented + +from .base_client import BaseAIClient + + +class GrokClient(BaseAIClient): + """Grok client with MCP integration (Placeholder Implementation)""" + + def __init__( + self, + model_name: str = "grok-1", + api_key: Optional[str] = None, + **kwargs + ): + # Note: This is a placeholder. You'll need xAI API integration + super().__init__(model_name, "grok", api_key, **kwargs) + + # Grok specific configuration + self._temperature = kwargs.get("temperature", 0.7) + self._max_tokens = kwargs.get("max_tokens", 1000) + + async def _initialize_client(self) -> None: + """Initialize the Grok client""" + # TODO: Implement with xAI API or SDK + # This might require direct HTTP calls to xAI API + raise NotImplementedError("Grok client not yet implemented. Implement xAI API integration.") + + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform Grok chat completion""" + if not self._initialized: + await self.initialize() + + # TODO: Implement Grok API call + raise NotImplementedError("Grok chat completion not yet implemented") + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for Grok's expected format""" + # TODO: Implement Grok tool formatting + return tools + + async def _cleanup_client(self) -> None: + """Clean up Grok client""" + # TODO: Implement cleanup + pass diff --git a/build/lib/mcp_template/llm_client/openai_client.py b/build/lib/mcp_template/llm_client/openai_client.py new file mode 100644 index 0000000..4076e32 --- /dev/null +++ b/build/lib/mcp_template/llm_client/openai_client.py @@ -0,0 +1,106 @@ +""" +OpenAI Client Implementation +""" +from typing import Any, Dict, List, Optional + +try: + from openai import AsyncOpenAI + OPENAI_AVAILABLE = True +except ImportError: + OPENAI_AVAILABLE = False + +from .base_client import BaseAIClient +from config import Config + +class OpenAIClient(BaseAIClient): + """OpenAI client with MCP integration""" + + def __init__( + self, + model_name: str = "gpt-4o", + api_key: Optional[str] = None, + **kwargs + ): + if not OPENAI_AVAILABLE: + raise ImportError("OpenAI package not installed. Install with: pip install openai") + + super().__init__(model_name, "openai", api_key, **kwargs) + + # OpenAI specific configuration + self._temperature = kwargs.get("temperature", 0.7) + self._max_tokens = kwargs.get("max_tokens", 1000) + self._api_key = api_key or Config.OPENAI_API_KEY + + async def _initialize_client(self) -> None: + """Initialize the OpenAI client""" + self._client = AsyncOpenAI(api_key=self._api_key) + + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform OpenAI chat completion""" + if not self._initialized: + await self.initialize() + + # Prepare request parameters + request_params = { + "model": self._model_name, + "messages": messages, + "temperature": self._temperature, + "max_tokens": self._max_tokens, + } + + # Add tools if provided + if tools: + request_params["tools"] = tools + request_params["tool_choice"] = kwargs.get("tool_choice", "auto") + + # Make the API call + response = await self._client.chat.completions.create(**request_params) + + # Convert to standard format + return { + "choices": [ + { + "message": { + "role": choice.message.role, + "content": choice.message.content, + "tool_calls": [ + { + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + } + } + for tool_call in (choice.message.tool_calls or []) + ] if choice.message.tool_calls else None, + } + } + for choice in response.choices + ] + } + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for OpenAI's expected format""" + formatted_tools = [] + for tool in tools: + formatted_tool = { + "type": "function", + "function": { + "name": tool["name"], + "description": tool["description"], + "parameters": tool["inputSchema"], + } + } + formatted_tools.append(formatted_tool) + return formatted_tools + + async def _cleanup_client(self) -> None: + """Clean up OpenAI client""" + if self._client: + await self._client.close() diff --git a/build/lib/mcp_template/mcp_client.py b/build/lib/mcp_template/mcp_client.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/mcp_template/server/__init__.py b/build/lib/mcp_template/server/__init__.py new file mode 100644 index 0000000..297e1c8 --- /dev/null +++ b/build/lib/mcp_template/server/__init__.py @@ -0,0 +1,5 @@ +# MCP Server implementations +from .modular_server import ModularMCPServer +from .server_factory import MCPServerFactory + +__all__ = ['ModularMCPServer', 'MCPServerFactory'] diff --git a/build/lib/mcp_template/server/modular_server.py b/build/lib/mcp_template/server/modular_server.py new file mode 100644 index 0000000..c09222a --- /dev/null +++ b/build/lib/mcp_template/server/modular_server.py @@ -0,0 +1,99 @@ +""" +Modular MCP Server Implementation +""" +from mcp.server.fastmcp import FastMCP +from typing import Optional, List +from .tools.tool_registry import ServerToolRegistry +from .prompts.prompt_registry import ServerPromptRegistry +from .resources.resource_registry import ServerResourceRegistry + + +class ModularMCPServer: + """Modular MCP Server that automatically discovers and registers tools, prompts, and resources""" + + def __init__( + self, + name: str, + host: str = "0.0.0.0", + port: int = 8050, + stateless_http: bool = True, + tools_directory: Optional[str] = None, + prompts_directory: Optional[str] = None, + resources_directory: Optional[str] = None + ): + self.name = name + self.host = host + self.port = port + self.stateless_http = stateless_http + + # Initialize registries + self.tool_registry = ServerToolRegistry(tools_directory) + self.prompt_registry = ServerPromptRegistry(prompts_directory) + self.resource_registry = ServerResourceRegistry(resources_directory) + + # Create FastMCP server + self.mcp = FastMCP( + name=name, + host=host, + port=port, + stateless_http=stateless_http + ) + + self._initialized = False + + async def initialize(self) -> None: + """Initialize the server and register all components""" + if self._initialized: + return + + print(f"Initializing {self.name} server...") + + # Discover and register tools + print("Discovering tools...") + self.tool_registry.register_tools_with_server(self.mcp) + tool_count = len(self.tool_registry.get_all_tools()) + print(f"Registered {tool_count} tools") + + # Discover and register prompts + print("Discovering prompts...") + self.prompt_registry.register_prompts_with_server(self.mcp) + prompt_count = len(self.prompt_registry.get_all_prompts()) + print(f"Registered {prompt_count} prompts") + + # Discover and register resources + print("Discovering resources...") + self.resource_registry.register_resources_with_server(self.mcp) + resource_count = len(self.resource_registry.get_all_resources()) + print(f"Registered {resource_count} resources") + + self._initialized = True + print(f"Server initialization complete!") + + def run(self, transport: str = "stdio") -> None: + """Run the server with the specified transport""" + if not self._initialized: + import asyncio + asyncio.run(self.initialize()) + + print(f"Starting {self.name} server with {transport} transport...") + self.mcp.run(transport=transport) + + def get_server_info(self) -> dict: + """Get information about the server and its components""" + return { + "name": self.name, + "host": self.host, + "port": self.port, + "tools": { + "count": len(self.tool_registry.get_all_tools()), + "names": self.tool_registry.get_tool_names() + }, + "prompts": { + "count": len(self.prompt_registry.get_all_prompts()), + "names": self.prompt_registry.get_prompt_names() + }, + "resources": { + "count": len(self.resource_registry.get_all_resources()), + "uris": self.resource_registry.get_resource_uris() + } + } diff --git a/build/lib/mcp_template/server/prompts/__init__.py b/build/lib/mcp_template/server/prompts/__init__.py new file mode 100644 index 0000000..de4d4a0 --- /dev/null +++ b/build/lib/mcp_template/server/prompts/__init__.py @@ -0,0 +1,7 @@ +""" +Server Prompts Module +""" +from .base_prompt import BaseServerPrompt +from .prompt_registry import ServerPromptRegistry + +__all__ = ['BaseServerPrompt', 'ServerPromptRegistry'] diff --git a/build/lib/mcp_template/server/prompts/base_prompt.py b/build/lib/mcp_template/server/prompts/base_prompt.py new file mode 100644 index 0000000..f5858ce --- /dev/null +++ b/build/lib/mcp_template/server/prompts/base_prompt.py @@ -0,0 +1,51 @@ +""" +Base Server Prompt Class +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class BaseServerPrompt(ABC): + """Base class for server prompts that can be registered with FastMCP""" + + def __init__(self, name: str, description: str, template: str, arguments: Optional[Dict[str, Any]] = None): + self.name = name + self.description = description + self.template = template + self.arguments = arguments or {} + + @abstractmethod + async def generate(self, **kwargs) -> str: + """Generate the prompt with the provided arguments""" + pass + + def get_prompt_definition(self) -> Dict[str, Any]: + """Get the prompt definition for FastMCP registration""" + return { + "name": self.name, + "description": self.description, + "arguments": self.arguments + } + + def create_fastmcp_prompt(self, mcp_server): + """Create a FastMCP prompt decorator for this prompt""" + @mcp_server.prompt() + async def prompt_wrapper(**kwargs): + return await self.generate(**kwargs) + + # Set metadata + prompt_wrapper.__name__ = self.name + prompt_wrapper.__doc__ = self.description + + return prompt_wrapper + + def _substitute_template(self, **kwargs) -> str: + """Helper method to substitute variables in template""" + result = self.template + + # Apply provided arguments + for key, value in kwargs.items(): + placeholder = f"{{{key}}}" + result = result.replace(placeholder, str(value)) + + return result diff --git a/build/lib/mcp_template/server/prompts/greeting_prompt.py b/build/lib/mcp_template/server/prompts/greeting_prompt.py new file mode 100644 index 0000000..f0f02ba --- /dev/null +++ b/build/lib/mcp_template/server/prompts/greeting_prompt.py @@ -0,0 +1,44 @@ +""" +Greeting Prompt Example +""" +from .base_prompt import BaseServerPrompt + + +class GreetingPrompt(BaseServerPrompt): + """A greeting prompt template""" + + def __init__(self): + super().__init__( + name="greeting_prompt", + description="Generate a greeting prompt for AI models", + template="Please write a {style} greeting for someone named {name}. The greeting should be {tone} and include a {element}.", + arguments={ + "style": { + "type": "string", + "enum": ["friendly", "formal", "casual"], + "description": "Style of greeting", + "default": "friendly" + }, + "tone": { + "type": "string", + "enum": ["warm", "professional", "relaxed"], + "description": "Tone of the greeting", + "default": "warm" + }, + "element": { + "type": "string", + "enum": ["compliment", "question", "observation"], + "description": "Element to include in greeting", + "default": "compliment" + } + } + ) + + async def generate(self, name: str, style: str = "friendly", tone: str = "warm", element: str = "compliment") -> str: + """Generate the greeting prompt""" + return self._substitute_template( + name=name, + style=style, + tone=tone, + element=element + ) diff --git a/build/lib/mcp_template/server/prompts/prompt_registry.py b/build/lib/mcp_template/server/prompts/prompt_registry.py new file mode 100644 index 0000000..92c802a --- /dev/null +++ b/build/lib/mcp_template/server/prompts/prompt_registry.py @@ -0,0 +1,100 @@ +""" +Server Prompt Registry for dynamic prompt registration +""" +import os +import importlib +import inspect +from typing import List, Dict, Any, Type, Optional +from pathlib import Path +from .base_prompt import BaseServerPrompt + + +class ServerPromptRegistry: + """Registry for managing server prompts from files""" + + def __init__(self, prompts_directory: str = None): + self.prompts_directory = prompts_directory or os.path.dirname(__file__) + self._registered_prompts: Dict[str, BaseServerPrompt] = {} + self._prompt_files: List[str] = [] + + @property + def directory(self): + """Alias for prompts_directory for backward compatibility""" + return self.prompts_directory + + @property + def _prompts(self): + """Alias for _registered_prompts for backward compatibility""" + return self._registered_prompts + + def discover_prompts(self) -> List[BaseServerPrompt]: + """Discover all prompts in the prompts directory""" + prompts = [] + prompts_dir = Path(self.prompts_directory) + + if not prompts_dir.exists(): + return prompts + + # Find all Python files in the prompts directory + for file_path in prompts_dir.glob("*.py"): + if file_path.name.startswith("__"): + continue + + module_name = file_path.stem + try: + # Import the module + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find all BaseServerPrompt subclasses in the module + for name, obj in inspect.getmembers(module, inspect.isclass): + if (issubclass(obj, BaseServerPrompt) and + obj != BaseServerPrompt and + not inspect.isabstract(obj)): + try: + prompt_instance = obj() + prompts.append(prompt_instance) + self._registered_prompts[prompt_instance.name] = prompt_instance + except Exception as e: + print(f"Warning: Could not instantiate prompt {name}: {e}") + + except Exception as e: + print(f"Warning: Could not load prompt file {file_path}: {e}") + + return prompts + + def register_prompt(self, prompt: BaseServerPrompt) -> None: + """Register a single prompt""" + self._registered_prompts[prompt.name] = prompt + + def register_prompts(self, prompts: List[BaseServerPrompt]) -> None: + """Register multiple prompts""" + for prompt in prompts: + self.register_prompt(prompt) + + def get_prompt(self, name: str) -> Optional[BaseServerPrompt]: + """Get a prompt by name""" + return self._registered_prompts.get(name) + + def get_all_prompts(self) -> List[BaseServerPrompt]: + """Get all registered prompts""" + return list(self._registered_prompts.values()) + + def get_prompt_names(self) -> List[str]: + """Get all registered prompt names""" + return list(self._registered_prompts.keys()) + + def register_prompts_with_server(self, mcp_server) -> None: + """Register all discovered prompts with a FastMCP server""" + # First discover prompts if not already done + if not self._registered_prompts: + self.discover_prompts() + + # Register each prompt with the server + for prompt in self._registered_prompts.values(): + prompt.create_fastmcp_prompt(mcp_server) + + def clear_prompts(self) -> None: + """Clear all registered prompts""" + self._registered_prompts.clear() diff --git a/build/lib/mcp_template/server/resources/__init__.py b/build/lib/mcp_template/server/resources/__init__.py new file mode 100644 index 0000000..d6b9d57 --- /dev/null +++ b/build/lib/mcp_template/server/resources/__init__.py @@ -0,0 +1,7 @@ +""" +Server Resources Module +""" +from .base_resource import BaseServerResource +from .resource_registry import ServerResourceRegistry + +__all__ = ['BaseServerResource', 'ServerResourceRegistry'] diff --git a/build/lib/mcp_template/server/resources/base_resource.py b/build/lib/mcp_template/server/resources/base_resource.py new file mode 100644 index 0000000..64d797e --- /dev/null +++ b/build/lib/mcp_template/server/resources/base_resource.py @@ -0,0 +1,41 @@ +""" +Base Server Resource Class +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Union + + +class BaseServerResource(ABC): + """Base class for server resources that can be registered with FastMCP""" + + def __init__(self, uri: str, name: str, description: str, mime_type: str = "text/plain"): + self.uri = uri + self.name = name + self.description = description + self.mime_type = mime_type + + @abstractmethod + async def get_content(self, **kwargs) -> Union[str, bytes]: + """Get the resource content with the provided arguments""" + pass + + def get_resource_definition(self) -> Dict[str, Any]: + """Get the resource definition for FastMCP registration""" + return { + "uri": self.uri, + "name": self.name, + "description": self.description, + "mime_type": self.mime_type + } + + def create_fastmcp_resource(self, mcp_server): + """Create a FastMCP resource decorator for this resource""" + @mcp_server.resource(self.uri) + async def resource_wrapper(**kwargs): + return await self.get_content(**kwargs) + + # Set metadata + resource_wrapper.__name__ = self.name + resource_wrapper.__doc__ = self.description + + return resource_wrapper diff --git a/build/lib/mcp_template/server/resources/config_resource.py b/build/lib/mcp_template/server/resources/config_resource.py new file mode 100644 index 0000000..d2dfc29 --- /dev/null +++ b/build/lib/mcp_template/server/resources/config_resource.py @@ -0,0 +1,37 @@ +""" +Configuration Resource Example +""" +from .base_resource import BaseServerResource + + +class ConfigResource(BaseServerResource): + """A configuration resource that provides server settings""" + + def __init__(self): + super().__init__( + uri="config://settings", + name="Server Configuration", + description="Get the current server configuration settings", + mime_type="application/json" + ) + + async def get_content(self) -> str: + """Get the configuration content""" + import json + config = { + "server_name": "MCP Template Server", + "version": "1.0.0", + "features": [ + "tools", + "prompts", + "resources" + ], + "transport": { + "supported": ["stdio", "sse"], + "default": "stdio" + }, + "tools_count": 2, + "prompts_count": 1, + "resources_count": 1 + } + return json.dumps(config, indent=2) diff --git a/build/lib/mcp_template/server/resources/dynamic_resource.py b/build/lib/mcp_template/server/resources/dynamic_resource.py new file mode 100644 index 0000000..f3ec1f6 --- /dev/null +++ b/build/lib/mcp_template/server/resources/dynamic_resource.py @@ -0,0 +1,28 @@ +""" +Dynamic Resource Example +""" +from .base_resource import BaseServerResource + + +class DynamicResource(BaseServerResource): + """A dynamic resource that accepts parameters""" + + def __init__(self): + super().__init__( + uri="dynamic://greeting/{name}", + name="Dynamic Greeting", + description="Get a personalized greeting resource", + mime_type="text/plain" + ) + + async def get_content(self, name: str) -> str: + """Get the dynamic greeting content""" + return f"Hello, {name}! This is a dynamic resource that was generated just for you.\n\n" \ + f"Resource URI: dynamic://greeting/{name}\n" \ + f"Generated at: {self._get_timestamp()}\n" \ + f"Personalized for: {name}" + + def _get_timestamp(self) -> str: + """Get current timestamp""" + from datetime import datetime + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") diff --git a/build/lib/mcp_template/server/resources/resource_registry.py b/build/lib/mcp_template/server/resources/resource_registry.py new file mode 100644 index 0000000..1356fc8 --- /dev/null +++ b/build/lib/mcp_template/server/resources/resource_registry.py @@ -0,0 +1,100 @@ +""" +Server Resource Registry for dynamic resource registration +""" +import os +import importlib +import inspect +from typing import List, Dict, Any, Type,Optional +from pathlib import Path +from .base_resource import BaseServerResource + + +class ServerResourceRegistry: + """Registry for managing server resources from files""" + + def __init__(self, resources_directory: str = None): + self.resources_directory = resources_directory or os.path.dirname(__file__) + self._registered_resources: Dict[str, BaseServerResource] = {} + self._resource_files: List[str] = [] + + @property + def directory(self): + """Alias for resources_directory for backward compatibility""" + return self.resources_directory + + @property + def _resources(self): + """Alias for _registered_resources for backward compatibility""" + return self._registered_resources + + def discover_resources(self) -> List[BaseServerResource]: + """Discover all resources in the resources directory""" + resources = [] + resources_dir = Path(self.resources_directory) + + if not resources_dir.exists(): + return resources + + # Find all Python files in the resources directory + for file_path in resources_dir.glob("*.py"): + if file_path.name.startswith("__"): + continue + + module_name = file_path.stem + try: + # Import the module + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find all BaseServerResource subclasses in the module + for name, obj in inspect.getmembers(module, inspect.isclass): + if (issubclass(obj, BaseServerResource) and + obj != BaseServerResource and + not inspect.isabstract(obj)): + try: + resource_instance = obj() + resources.append(resource_instance) + self._registered_resources[resource_instance.uri] = resource_instance + except Exception as e: + print(f"Warning: Could not instantiate resource {name}: {e}") + + except Exception as e: + print(f"Warning: Could not load resource file {file_path}: {e}") + + return resources + + def register_resource(self, resource: BaseServerResource) -> None: + """Register a single resource""" + self._registered_resources[resource.uri] = resource + + def register_resources(self, resources: List[BaseServerResource]) -> None: + """Register multiple resources""" + for resource in resources: + self.register_resource(resource) + + def get_resource(self, uri: str) -> Optional[BaseServerResource]: + """Get a resource by URI""" + return self._registered_resources.get(uri) + + def get_all_resources(self) -> List[BaseServerResource]: + """Get all registered resources""" + return list(self._registered_resources.values()) + + def get_resource_uris(self) -> List[str]: + """Get all registered resource URIs""" + return list(self._registered_resources.keys()) + + def register_resources_with_server(self, mcp_server) -> None: + """Register all discovered resources with a FastMCP server""" + # First discover resources if not already done + if not self._registered_resources: + self.discover_resources() + + # Register each resource with the server + for resource in self._registered_resources.values(): + resource.create_fastmcp_resource(mcp_server) + + def clear_resources(self) -> None: + """Clear all registered resources""" + self._registered_resources.clear() diff --git a/build/lib/mcp_template/server/server_factory.py b/build/lib/mcp_template/server/server_factory.py new file mode 100644 index 0000000..39ac0b0 --- /dev/null +++ b/build/lib/mcp_template/server/server_factory.py @@ -0,0 +1,258 @@ +""" +MCP Server Factory for easy server creation and configuration +""" +from typing import List, Optional, Union +from ..core.types import MCPServerConfig, MCPTool, MCPResource, MCPPrompt, TransportType +from .modular_server import ModularMCPServer +from ..core.interfaces import IMCPServer +from ..tools.tool_registry import ToolRegistry + + +class MCPServerFactory: + """Factory class for creating MCP servers with different configurations""" + + @staticmethod + def create_server( + name: str, + version: str = "1.0.0", + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + stateless_http: bool = True, + tools: Optional[List[MCPTool]] = None, + resources: Optional[List[MCPResource]] = None, + prompts: Optional[List[MCPPrompt]] = None, + ) -> IMCPServer: + """Create an MCP server with the specified configuration""" + + # Convert string transport to enum if needed + if isinstance(transport, str): + transport = TransportType(transport.lower()) + + config = MCPServerConfig( + name=name, + version=version, + transport=transport, + host=host, + port=port, + stateless_http=stateless_http, + tools=tools or [], + resources=resources or [], + prompts=prompts or [], + ) + + return ModularMCPServer( + name=config.name, + host=config.host, + port=config.port, + stateless_http=config.stateless_http + ) + + @staticmethod + def create_basic_calculator_server( + name: str = "CalculatorServer", + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + ) -> IMCPServer: + """Create a basic calculator server with common math tools""" + + async def add(a: float, b: float) -> float: + """Add two numbers together""" + return a + b + + async def subtract(a: float, b: float) -> float: + """Subtract b from a""" + return a - b + + async def multiply(a: float, b: float) -> float: + """Multiply two numbers""" + return a * b + + async def divide(a: float, b: float) -> float: + """Divide a by b""" + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + tools = [ + MCPTool( + name="add", + description="Add two numbers together", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=add, + ), + MCPTool( + name="subtract", + description="Subtract second number from first", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Number to subtract"}, + }, + "required": ["a", "b"], + }, + handler=subtract, + ), + MCPTool( + name="multiply", + description="Multiply two numbers", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=multiply, + ), + MCPTool( + name="divide", + description="Divide first number by second", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "Dividend"}, + "b": {"type": "number", "description": "Divisor (cannot be zero)"}, + }, + "required": ["a", "b"], + }, + handler=divide, + ), + ] + + return MCPServerFactory.create_server( + name=name, + transport=transport, + host=host, + port=port, + tools=tools, + ) + + @staticmethod + def create_knowledge_base_server( + name: str = "KnowledgeBaseServer", + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + kb_data: Optional[dict] = None, + ) -> IMCPServer: + """Create a knowledge base server with configurable data""" + + if kb_data is None: + kb_data = { + "company_policy": "Default company policy information...", + "faq": "Frequently asked questions and answers...", + } + + async def get_knowledge_base() -> str: + """Retrieve the entire knowledge base as formatted string""" + formatted = "Knowledge Base:\n\n" + for key, value in kb_data.items(): + formatted += f"**{key.replace('_', ' ').title()}:**\n{value}\n\n" + return formatted + + async def search_kb(query: str) -> str: + """Search the knowledge base for relevant information""" + query_lower = query.lower() + results = [] + + for key, value in kb_data.items(): + if query_lower in key.lower() or query_lower in value.lower(): + results.append(f"**{key.replace('_', ' ').title()}:**\n{value}") + + if not results: + return f"No information found for query: {query}" + + return "\n\n".join(results) + + tools = [ + MCPTool( + name="get_knowledge_base", + description="Retrieve the entire knowledge base as a formatted string", + input_schema={"type": "object", "properties": {}}, + handler=get_knowledge_base, + ), + MCPTool( + name="search_kb", + description="Search the knowledge base for relevant information", + input_schema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + }, + handler=search_kb, + ), + ] + + return MCPServerFactory.create_server( + name=name, + transport=transport, + host=host, + port=port, + tools=tools, + ) + + @staticmethod + def create_server_from_categories( + name: str, + categories: List[str], + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + custom_tools: Optional[List[MCPTool]] = None, + resources: Optional[List[MCPResource]] = None, + prompts: Optional[List[MCPPrompt]] = None, + ) -> IMCPServer: + """Create a server using tool categories""" + + # Get tools from categories + registry = ToolRegistry() + category_tools = registry.get_tools_by_categories(categories) + + # Combine with custom tools + all_tools = category_tools + if custom_tools: + all_tools.extend(custom_tools) + + return MCPServerFactory.create_server( + name=name, + transport=transport, + host=host, + port=port, + tools=all_tools, + resources=resources, + prompts=prompts, + ) + + @staticmethod + def create_modular_server( + name: str = "ModularServer", + host: str = "0.0.0.0", + port: int = 8050, + stateless_http: bool = True, + tools_directory: Optional[str] = None, + prompts_directory: Optional[str] = None, + resources_directory: Optional[str] = None, + ) -> ModularMCPServer: + """Create a modular server that auto-discovers tools, prompts, and resources""" + return ModularMCPServer( + name=name, + host=host, + port=port, + stateless_http=stateless_http, + tools_directory=tools_directory, + prompts_directory=prompts_directory, + resources_directory=resources_directory, + ) diff --git a/build/lib/mcp_template/server/tools/__init__.py b/build/lib/mcp_template/server/tools/__init__.py new file mode 100644 index 0000000..2480785 --- /dev/null +++ b/build/lib/mcp_template/server/tools/__init__.py @@ -0,0 +1,7 @@ +""" +Server Tools Module +""" +from .base_tool import BaseServerTool +from .tool_registry import ServerToolRegistry + +__all__ = ['BaseServerTool', 'ServerToolRegistry'] diff --git a/build/lib/mcp_template/server/tools/base_tool.py b/build/lib/mcp_template/server/tools/base_tool.py new file mode 100644 index 0000000..a224ef2 --- /dev/null +++ b/build/lib/mcp_template/server/tools/base_tool.py @@ -0,0 +1,67 @@ +""" +Base Server Tool Class +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from mcp.server.fastmcp import Context +from mcp.server.session import ServerSession + + +class BaseServerTool(ABC): + """Base class for server tools that can be registered with FastMCP""" + + def __init__(self, name: str, description: str, input_schema: Dict[str, Any]): + self.name = name + self.description = description + self.input_schema = input_schema + + @abstractmethod + async def execute(self, **kwargs) -> Any: + """Execute the tool with the provided arguments""" + pass + + def get_tool_definition(self) -> Dict[str, Any]: + """Get the tool definition for FastMCP registration""" + return { + "name": self.name, + "description": self.description, + "input_schema": self.input_schema + } + + def create_fastmcp_tool(self, mcp_server): + """Create a FastMCP tool decorator for this tool""" + @mcp_server.tool() + async def tool_wrapper(**kwargs): + return await self.execute(**kwargs) + + # Set metadata + tool_wrapper.__name__ = self.name + tool_wrapper.__doc__ = self.description + + return tool_wrapper + + +class ContextAwareTool(BaseServerTool): + """Base class for tools that need access to MCP context""" + + @abstractmethod + async def execute_with_context(self, ctx: Context[ServerSession, None], **kwargs) -> Any: + """Execute the tool with MCP context""" + pass + + async def execute(self, **kwargs) -> Any: + """Default implementation that doesn't use context""" + # This will be overridden by the FastMCP wrapper + raise NotImplementedError("Use execute_with_context for context-aware tools") + + def create_fastmcp_tool(self, mcp_server): + """Create a FastMCP tool decorator for this context-aware tool""" + @mcp_server.tool() + async def tool_wrapper(ctx: Context[ServerSession, None], **kwargs): + return await self.execute_with_context(ctx, **kwargs) + + # Set metadata + tool_wrapper.__name__ = self.name + tool_wrapper.__doc__ = self.description + + return tool_wrapper diff --git a/build/lib/mcp_template/server/tools/calculator_tool.py b/build/lib/mcp_template/server/tools/calculator_tool.py new file mode 100644 index 0000000..05fed15 --- /dev/null +++ b/build/lib/mcp_template/server/tools/calculator_tool.py @@ -0,0 +1,48 @@ +""" +Calculator Tool Example +""" +from .base_tool import BaseServerTool + + +class CalculatorTool(BaseServerTool): + """A simple calculator tool""" + + def __init__(self): + super().__init__( + name="calculator", + description="Perform basic mathematical operations", + input_schema={ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The mathematical operation to perform" + }, + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["operation", "a", "b"] + } + ) + + async def execute(self, operation: str, a: float, b: float) -> float: + """Execute the calculator operation""" + if operation == "add": + return a + b + elif operation == "subtract": + return a - b + elif operation == "multiply": + return a * b + elif operation == "divide": + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + else: + raise ValueError(f"Unknown operation: {operation}") diff --git a/build/lib/mcp_template/server/tools/greeting_tool.py b/build/lib/mcp_template/server/tools/greeting_tool.py new file mode 100644 index 0000000..3172e49 --- /dev/null +++ b/build/lib/mcp_template/server/tools/greeting_tool.py @@ -0,0 +1,54 @@ +""" +Greeting Tool Example +""" +from .base_tool import ContextAwareTool +from mcp.server.fastmcp import Context +from mcp.server.session import ServerSession + + +class GreetingTool(ContextAwareTool): + """A greeting tool that uses MCP context""" + + def __init__(self): + super().__init__( + name="greeting", + description="Generate personalized greetings with progress updates", + input_schema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the person to greet" + }, + "style": { + "type": "string", + "enum": ["friendly", "formal", "casual"], + "description": "Style of greeting", + "default": "friendly" + } + }, + "required": ["name"] + } + ) + + async def execute_with_context(self, ctx: Context[ServerSession, None], name: str, style: str = "friendly") -> str: + """Generate a greeting with progress updates""" + await ctx.info(f"Generating {style} greeting for {name}") + + # Simulate some work with progress updates + await ctx.report_progress(progress=0.3, total=1.0, message="Preparing greeting...") + + styles = { + "friendly": f"Hello there, {name}! Great to see you!", + "formal": f"Good day, {name}. I hope you are well.", + "casual": f"Hey {name}! What's up?" + } + + await ctx.report_progress(progress=0.7, total=1.0, message="Generating message...") + + greeting = styles.get(style, styles["friendly"]) + + await ctx.report_progress(progress=1.0, total=1.0, message="Greeting complete!") + await ctx.debug(f"Generated greeting: {greeting}") + + return greeting diff --git a/build/lib/mcp_template/server/tools/tool_registry.py b/build/lib/mcp_template/server/tools/tool_registry.py new file mode 100644 index 0000000..54fb113 --- /dev/null +++ b/build/lib/mcp_template/server/tools/tool_registry.py @@ -0,0 +1,100 @@ +""" +Server Tool Registry for dynamic tool registration +""" +import os +import importlib +import inspect +from typing import List, Dict, Any, Type,Optional +from pathlib import Path +from .base_tool import BaseServerTool + + +class ServerToolRegistry: + """Registry for managing server tools from files""" + + def __init__(self, tools_directory: str = None): + self.tools_directory = tools_directory or os.path.dirname(__file__) + self._registered_tools: Dict[str, BaseServerTool] = {} + self._tool_files: List[str] = [] + + @property + def directory(self): + """Alias for tools_directory for backward compatibility""" + return self.tools_directory + + @property + def _tools(self): + """Alias for _registered_tools for backward compatibility""" + return self._registered_tools + + def discover_tools(self) -> List[BaseServerTool]: + """Discover all tools in the tools directory""" + tools = [] + tools_dir = Path(self.tools_directory) + + if not tools_dir.exists(): + return tools + + # Find all Python files in the tools directory + for file_path in tools_dir.glob("*.py"): + if file_path.name.startswith("__"): + continue + + module_name = file_path.stem + try: + # Import the module + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find all BaseServerTool subclasses in the module + for name, obj in inspect.getmembers(module, inspect.isclass): + if (issubclass(obj, BaseServerTool) and + obj != BaseServerTool and + not inspect.isabstract(obj)): + try: + tool_instance = obj() + tools.append(tool_instance) + self._registered_tools[tool_instance.name] = tool_instance + except Exception as e: + print(f"Warning: Could not instantiate tool {name}: {e}") + + except Exception as e: + print(f"Warning: Could not load tool file {file_path}: {e}") + + return tools + + def register_tool(self, tool: BaseServerTool) -> None: + """Register a single tool""" + self._registered_tools[tool.name] = tool + + def register_tools(self, tools: List[BaseServerTool]) -> None: + """Register multiple tools""" + for tool in tools: + self.register_tool(tool) + + def get_tool(self, name: str) -> Optional[BaseServerTool]: + """Get a tool by name""" + return self._registered_tools.get(name) + + def get_all_tools(self) -> List[BaseServerTool]: + """Get all registered tools""" + return list(self._registered_tools.values()) + + def get_tool_names(self) -> List[str]: + """Get all registered tool names""" + return list(self._registered_tools.keys()) + + def register_tools_with_server(self, mcp_server) -> None: + """Register all discovered tools with a FastMCP server""" + # First discover tools if not already done + if not self._registered_tools: + self.discover_tools() + + # Register each tool with the server + for tool in self._registered_tools.values(): + tool.create_fastmcp_tool(mcp_server) + + def clear_tools(self) -> None: + """Clear all registered tools""" + self._registered_tools.clear() diff --git a/build/lib/mcp_template/tools/__init__.py b/build/lib/mcp_template/tools/__init__.py new file mode 100644 index 0000000..533ef44 --- /dev/null +++ b/build/lib/mcp_template/tools/__init__.py @@ -0,0 +1,8 @@ +# MCP Tools Collection +from .math_tools import MathTools +from .text_tools import TextTools +from .system_tools import SystemTools +from .web_tools import WebTools +from .tool_registry import ToolRegistry + +__all__ = ['MathTools', 'TextTools', 'SystemTools', 'WebTools', 'ToolRegistry'] diff --git a/build/lib/mcp_template/tools/math_tools.py b/build/lib/mcp_template/tools/math_tools.py new file mode 100644 index 0000000..185c1d7 --- /dev/null +++ b/build/lib/mcp_template/tools/math_tools.py @@ -0,0 +1,186 @@ +""" +Mathematical Tools for MCP +""" +from typing import List +from ..core.types import MCPTool + + +class MathTools: + """Collection of mathematical tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all math tools""" + return [ + MathTools._create_add_tool(), + MathTools._create_subtract_tool(), + MathTools._create_multiply_tool(), + MathTools._create_divide_tool(), + MathTools._create_power_tool(), + MathTools._create_square_root_tool(), + MathTools._create_calculate_bmi_tool(), + ] + + @staticmethod + def _create_add_tool() -> MCPTool: + """Create addition tool""" + async def add(a: float, b: float) -> float: + """Add two numbers together""" + return a + b + + return MCPTool( + name="add", + description="Add two numbers together", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=add, + ) + + @staticmethod + def _create_subtract_tool() -> MCPTool: + """Create subtraction tool""" + async def subtract(a: float, b: float) -> float: + """Subtract second number from first""" + return a - b + + return MCPTool( + name="subtract", + description="Subtract second number from first", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Number to subtract"}, + }, + "required": ["a", "b"], + }, + handler=subtract, + ) + + @staticmethod + def _create_multiply_tool() -> MCPTool: + """Create multiplication tool""" + async def multiply(a: float, b: float) -> float: + """Multiply two numbers""" + return a * b + + return MCPTool( + name="multiply", + description="Multiply two numbers", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=multiply, + ) + + @staticmethod + def _create_divide_tool() -> MCPTool: + """Create division tool""" + async def divide(a: float, b: float) -> float: + """Divide first number by second""" + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + return MCPTool( + name="divide", + description="Divide first number by second", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "Dividend"}, + "b": {"type": "number", "description": "Divisor (cannot be zero)"}, + }, + "required": ["a", "b"], + }, + handler=divide, + ) + + @staticmethod + def _create_power_tool() -> MCPTool: + """Create power tool""" + async def power(base: float, exponent: float) -> float: + """Calculate base raised to the power of exponent""" + return base ** exponent + + return MCPTool( + name="power", + description="Calculate base raised to the power of exponent", + input_schema={ + "type": "object", + "properties": { + "base": {"type": "number", "description": "Base number"}, + "exponent": {"type": "number", "description": "Exponent"}, + }, + "required": ["base", "exponent"], + }, + handler=power, + ) + + @staticmethod + def _create_square_root_tool() -> MCPTool: + """Create square root tool""" + async def square_root(number: float) -> float: + """Calculate square root of a number""" + if number < 0: + raise ValueError("Cannot calculate square root of negative number") + return number ** 0.5 + + return MCPTool( + name="square_root", + description="Calculate square root of a number", + input_schema={ + "type": "object", + "properties": { + "number": {"type": "number", "description": "Number to find square root of (must be non-negative)"}, + }, + "required": ["number"], + }, + handler=square_root, + ) + + @staticmethod + def _create_calculate_bmi_tool() -> MCPTool: + """Create BMI calculation tool""" + async def calculate_bmi(weight_kg: float, height_m: float) -> str: + """Calculate BMI and provide health category""" + if weight_kg <= 0 or height_m <= 0: + raise ValueError("Weight and height must be positive numbers") + + bmi = weight_kg / (height_m ** 2) + + if bmi < 18.5: + category = "Underweight" + elif bmi < 25: + category = "Normal weight" + elif bmi < 30: + category = "Overweight" + else: + category = "Obese" + + return ".1f" + + return MCPTool( + name="calculate_bmi", + description="Calculate BMI and provide health assessment", + input_schema={ + "type": "object", + "properties": { + "weight_kg": {"type": "number", "description": "Weight in kilograms"}, + "height_m": {"type": "number", "description": "Height in meters"}, + }, + "required": ["weight_kg", "height_m"], + }, + handler=calculate_bmi, + ) diff --git a/build/lib/mcp_template/tools/system_tools.py b/build/lib/mcp_template/tools/system_tools.py new file mode 100644 index 0000000..de6ee55 --- /dev/null +++ b/build/lib/mcp_template/tools/system_tools.py @@ -0,0 +1,157 @@ +""" +System Tools for MCP +""" +import os +import platform +import psutil +from datetime import datetime +from typing import Dict, Any, List +from ..core.types import MCPTool + + +class SystemTools: + """Collection of system-related tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all system tools""" + return [ + SystemTools._create_get_system_info_tool(), + SystemTools._create_get_current_time_tool(), + SystemTools._create_list_directory_tool(), + SystemTools._create_get_file_info_tool(), + SystemTools._create_get_environment_variable_tool(), + ] + + @staticmethod + def _create_get_system_info_tool() -> MCPTool: + """Create system info tool""" + async def get_system_info() -> Dict[str, Any]: + """Get basic system information""" + try: + return { + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "cpu_count": os.cpu_count(), + "memory_total": psutil.virtual_memory().total if psutil else "psutil not available", + "memory_available": psutil.virtual_memory().available if psutil else "psutil not available", + } + except Exception as e: + return {"error": f"Could not retrieve system info: {str(e)}"} + + return MCPTool( + name="get_system_info", + description="Get basic system information including OS, CPU, and memory details", + input_schema={"type": "object", "properties": {}}, + handler=get_system_info, + ) + + @staticmethod + def _create_get_current_time_tool() -> MCPTool: + """Create current time tool""" + async def get_current_time() -> str: + """Get the current date and time""" + now = datetime.now() + return now.strftime("%Y-%m-%d %H:%M:%S") + + return MCPTool( + name="get_current_time", + description="Get the current date and time in YYYY-MM-DD HH:MM:SS format", + input_schema={"type": "object", "properties": {}}, + handler=get_current_time, + ) + + @staticmethod + def _create_list_directory_tool() -> MCPTool: + """Create directory listing tool""" + async def list_directory(path: str = ".") -> List[str]: + """List contents of a directory""" + try: + if not os.path.exists(path): + raise ValueError(f"Path does not exist: {path}") + + if not os.path.isdir(path): + raise ValueError(f"Path is not a directory: {path}") + + return os.listdir(path) + except Exception as e: + raise ValueError(f"Could not list directory: {str(e)}") + + return MCPTool( + name="list_directory", + description="List the contents of a directory", + input_schema={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path to list", + "default": "." + }, + }, + }, + handler=list_directory, + ) + + @staticmethod + def _create_get_file_info_tool() -> MCPTool: + """Create file info tool""" + async def get_file_info(file_path: str) -> Dict[str, Any]: + """Get information about a file""" + try: + if not os.path.exists(file_path): + raise ValueError(f"File does not exist: {file_path}") + + stat = os.stat(file_path) + return { + "name": os.path.basename(file_path), + "path": os.path.abspath(file_path), + "size": stat.st_size, + "is_file": os.path.isfile(file_path), + "is_directory": os.path.isdir(file_path), + "modified_time": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "created_time": datetime.fromtimestamp(stat.st_ctime).isoformat(), + } + except Exception as e: + raise ValueError(f"Could not get file info: {str(e)}") + + return MCPTool( + name="get_file_info", + description="Get detailed information about a file or directory", + input_schema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the file or directory"}, + }, + "required": ["file_path"], + }, + handler=get_file_info, + ) + + @staticmethod + def _create_get_environment_variable_tool() -> MCPTool: + """Create environment variable tool""" + async def get_environment_variable(name: str, default_value: str = "") -> str: + """Get the value of an environment variable""" + return os.getenv(name, default_value) + + return MCPTool( + name="get_environment_variable", + description="Get the value of an environment variable", + input_schema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Name of the environment variable"}, + "default_value": { + "type": "string", + "description": "Default value if variable is not set", + "default": "" + }, + }, + "required": ["name"], + }, + handler=get_environment_variable, + ) diff --git a/build/lib/mcp_template/tools/text_tools.py b/build/lib/mcp_template/tools/text_tools.py new file mode 100644 index 0000000..c7ae0ac --- /dev/null +++ b/build/lib/mcp_template/tools/text_tools.py @@ -0,0 +1,187 @@ +""" +Text Processing Tools for MCP +""" +import re +from typing import List +from ..core.types import MCPTool + + +class TextTools: + """Collection of text processing tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all text tools""" + return [ + TextTools._create_word_count_tool(), + TextTools._create_text_search_tool(), + TextTools._create_text_replace_tool(), + TextTools._create_text_uppercase_tool(), + TextTools._create_text_lowercase_tool(), + TextTools._create_text_length_tool(), + TextTools._create_generate_greeting_tool(), + ] + + @staticmethod + def _create_word_count_tool() -> MCPTool: + """Create word count tool""" + async def count_words(text: str) -> int: + """Count the number of words in a text""" + words = text.strip().split() + return len(words) + + return MCPTool( + name="count_words", + description="Count the number of words in a text", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to count words in"}, + }, + "required": ["text"], + }, + handler=count_words, + ) + + @staticmethod + def _create_text_search_tool() -> MCPTool: + """Create text search tool""" + async def search_text(text: str, pattern: str, case_sensitive: bool = False) -> List[str]: + """Search for a pattern in text and return all matches""" + flags = 0 if case_sensitive else re.IGNORECASE + matches = re.findall(pattern, text, flags) + return matches + + return MCPTool( + name="search_text", + description="Search for a pattern in text and return all matches", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to search in"}, + "pattern": {"type": "string", "description": "Regular expression pattern to search for"}, + "case_sensitive": {"type": "boolean", "description": "Whether search should be case sensitive", "default": False}, + }, + "required": ["text", "pattern"], + }, + handler=search_text, + ) + + @staticmethod + def _create_text_replace_tool() -> MCPTool: + """Create text replace tool""" + async def replace_text(text: str, old_pattern: str, new_text: str) -> str: + """Replace all occurrences of a pattern in text""" + return text.replace(old_pattern, new_text) + + return MCPTool( + name="replace_text", + description="Replace all occurrences of a pattern in text", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Original text"}, + "old_pattern": {"type": "string", "description": "Text to replace"}, + "new_text": {"type": "string", "description": "Replacement text"}, + }, + "required": ["text", "old_pattern", "new_text"], + }, + handler=replace_text, + ) + + @staticmethod + def _create_text_uppercase_tool() -> MCPTool: + """Create uppercase tool""" + async def to_uppercase(text: str) -> str: + """Convert text to uppercase""" + return text.upper() + + return MCPTool( + name="to_uppercase", + description="Convert text to uppercase", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to convert to uppercase"}, + }, + "required": ["text"], + }, + handler=to_uppercase, + ) + + @staticmethod + def _create_text_lowercase_tool() -> MCPTool: + """Create lowercase tool""" + async def to_lowercase(text: str) -> str: + """Convert text to lowercase""" + return text.lower() + + return MCPTool( + name="to_lowercase", + description="Convert text to lowercase", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to convert to lowercase"}, + }, + "required": ["text"], + }, + handler=to_lowercase, + ) + + @staticmethod + def _create_text_length_tool() -> MCPTool: + """Create text length tool""" + async def text_length(text: str) -> int: + """Get the length of text""" + return len(text) + + return MCPTool( + name="text_length", + description="Get the length of text (number of characters)", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to measure length of"}, + }, + "required": ["text"], + }, + handler=text_length, + ) + + @staticmethod + def _create_generate_greeting_tool() -> MCPTool: + """Create greeting generation tool""" + async def generate_greeting(name: str, style: str = "casual") -> str: + """Generate a personalized greeting""" + name = name.strip() + if not name: + raise ValueError("Name cannot be empty") + + if style == "casual": + return f"Hey {name}! Welcome! 👋" + elif style == "formal": + return f"Good day, {name}. Welcome to our platform." + elif style == "professional": + return f"Hello, {name}. Thank you for joining us." + else: + return f"Hi {name}! Welcome!" + + return MCPTool( + name="generate_greeting", + description="Generate a personalized greeting with different styles", + input_schema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Person's name"}, + "style": { + "type": "string", + "enum": ["casual", "formal", "professional"], + "description": "Greeting style", + "default": "casual" + }, + }, + "required": ["name"], + }, + handler=generate_greeting, + ) diff --git a/build/lib/mcp_template/tools/tool_registry.py b/build/lib/mcp_template/tools/tool_registry.py new file mode 100644 index 0000000..52af662 --- /dev/null +++ b/build/lib/mcp_template/tools/tool_registry.py @@ -0,0 +1,80 @@ +""" +Tool Registry for easy tool management and combination +""" +from typing import List, Dict, Any, Optional +from ..core.types import MCPTool +from .math_tools import MathTools +from .text_tools import TextTools +from .system_tools import SystemTools +from .web_tools import WebTools + + +class ToolRegistry: + """Registry for managing and combining MCP tools from different categories""" + + def __init__(self): + self._tool_categories = { + 'math': MathTools, + 'text': TextTools, + 'system': SystemTools, + 'web': WebTools, + } + self._custom_tools: List[MCPTool] = [] + + def get_tools_by_category(self, category: str) -> List[MCPTool]: + """Get all tools from a specific category""" + if category not in self._tool_categories: + raise ValueError(f"Unknown tool category: {category}") + + return self._tool_categories[category].get_tools() + + def get_tools_by_categories(self, categories: List[str]) -> List[MCPTool]: + """Get tools from multiple categories""" + tools = [] + for category in categories: + tools.extend(self.get_tools_by_category(category)) + return tools + + def get_all_tools(self) -> List[MCPTool]: + """Get all tools from all categories""" + tools = [] + for category in self._tool_categories.values(): + tools.extend(category.get_tools()) + tools.extend(self._custom_tools) + return tools + + def add_custom_tool(self, tool: MCPTool) -> None: + """Add a custom tool to the registry""" + self._custom_tools.append(tool) + + def add_custom_tools(self, tools: List[MCPTool]) -> None: + """Add multiple custom tools to the registry""" + self._custom_tools.extend(tools) + + def get_available_categories(self) -> List[str]: + """Get list of available tool categories""" + return list(self._tool_categories.keys()) + + def get_category_info(self) -> Dict[str, Dict[str, Any]]: + """Get information about each category""" + info = {} + for category_name, category_class in self._tool_categories.items(): + tools = category_class.get_tools() + info[category_name] = { + 'tool_count': len(tools), + 'tool_names': [tool.name for tool in tools] + } + return info + + def create_server_config(self, categories: Optional[List[str]] = None) -> Dict[str, Any]: + """Create a server configuration with tools from specified categories""" + if categories is None: + tools = self.get_all_tools() + else: + tools = self.get_tools_by_categories(categories) + + return { + 'tools': tools, + 'tool_count': len(tools), + 'categories': categories or self.get_available_categories() + } diff --git a/build/lib/mcp_template/tools/web_tools.py b/build/lib/mcp_template/tools/web_tools.py new file mode 100644 index 0000000..7fd9b75 --- /dev/null +++ b/build/lib/mcp_template/tools/web_tools.py @@ -0,0 +1,161 @@ +""" +Web Tools for MCP +""" +import urllib.parse +from typing import Dict, Any, List, Optional +from ..core.types import MCPTool + + +class WebTools: + """Collection of web-related tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all web tools""" + return [ + WebTools._create_url_encode_tool(), + WebTools._create_url_decode_tool(), + WebTools._create_parse_url_tool(), + WebTools._create_validate_email_tool(), + WebTools._create_extract_domain_tool(), + ] + + @staticmethod + def _create_url_encode_tool() -> MCPTool: + """Create URL encode tool""" + async def url_encode(text: str) -> str: + """URL encode a string""" + return urllib.parse.quote(text) + + return MCPTool( + name="url_encode", + description="URL encode a string for safe transmission", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to URL encode"}, + }, + "required": ["text"], + }, + handler=url_encode, + ) + + @staticmethod + def _create_url_decode_tool() -> MCPTool: + """Create URL decode tool""" + async def url_decode(encoded_text: str) -> str: + """URL decode a string""" + try: + return urllib.parse.unquote(encoded_text) + except Exception: + raise ValueError("Invalid URL encoded string") + + return MCPTool( + name="url_decode", + description="URL decode a previously encoded string", + input_schema={ + "type": "object", + "properties": { + "encoded_text": {"type": "string", "description": "URL encoded text to decode"}, + }, + "required": ["encoded_text"], + }, + handler=url_decode, + ) + + @staticmethod + def _create_parse_url_tool() -> MCPTool: + """Create URL parsing tool""" + async def parse_url(url: str) -> Dict[str, str]: + """Parse a URL and return its components""" + try: + parsed = urllib.parse.urlparse(url) + return { + "scheme": parsed.scheme, + "netloc": parsed.netloc, + "hostname": parsed.hostname, + "port": str(parsed.port) if parsed.port else "", + "path": parsed.path, + "query": parsed.query, + "fragment": parsed.fragment, + } + except Exception: + raise ValueError("Invalid URL format") + + return MCPTool( + name="parse_url", + description="Parse a URL and return its components (scheme, host, path, etc.)", + input_schema={ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to parse"}, + }, + "required": ["url"], + }, + handler=parse_url, + ) + + @staticmethod + def _create_validate_email_tool() -> MCPTool: + """Create email validation tool""" + async def validate_email(email: str) -> Dict[str, Any]: + """Validate an email address format""" + import re + + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + is_valid = bool(re.match(email_pattern, email)) + + # Extract domain + if is_valid and '@' in email: + domain = email.split('@')[1] + else: + domain = "" + + return { + "email": email, + "is_valid": is_valid, + "domain": domain, + } + + return MCPTool( + name="validate_email", + description="Validate an email address format and extract domain", + input_schema={ + "type": "object", + "properties": { + "email": {"type": "string", "description": "Email address to validate"}, + }, + "required": ["email"], + }, + handler=validate_email, + ) + + @staticmethod + def _create_extract_domain_tool() -> MCPTool: + """Create domain extraction tool""" + async def extract_domain(url: str) -> str: + """Extract the domain from a URL""" + try: + parsed = urllib.parse.urlparse(url) + if parsed.hostname: + return parsed.hostname + else: + # Fallback for URLs without scheme + url_with_scheme = "http://" + url if "://" not in url else url + parsed = urllib.parse.urlparse(url_with_scheme) + return parsed.hostname or "" + except Exception: + return "" + + return MCPTool( + name="extract_domain", + description="Extract the domain name from a URL", + input_schema={ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to extract domain from"}, + }, + "required": ["url"], + }, + handler=extract_domain, + ) diff --git a/build/lib/mcp_template/transport/__init__.py b/build/lib/mcp_template/transport/__init__.py new file mode 100644 index 0000000..f9885a7 --- /dev/null +++ b/build/lib/mcp_template/transport/__init__.py @@ -0,0 +1,6 @@ +# Transport layer implementations +from .transport_manager import TransportManager +from .sse_transport import SSETransport +from .stdio_transport import STDIOTransport + +__all__ = ['TransportManager', 'SSETransport', 'STDIOTransport'] diff --git a/build/lib/mcp_template/transport/sse_transport.py b/build/lib/mcp_template/transport/sse_transport.py new file mode 100644 index 0000000..daf0d21 --- /dev/null +++ b/build/lib/mcp_template/transport/sse_transport.py @@ -0,0 +1,101 @@ +""" +SSE (Server-Sent Events) Transport Implementation +""" +import asyncio +import json +from typing import Dict, Any, Optional +from contextlib import asynccontextmanager + +try: + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + +from ..core.interfaces import IMCPTransport + + +class SSETransport(IMCPTransport): + """SSE transport for MCP communication""" + + def __init__( + self, + host: str = "localhost", + port: int = 8050, + endpoint: str = "/sse", + **kwargs + ): + if not AIOHTTP_AVAILABLE: + raise ImportError("aiohttp package not installed. Install with: pip install aiohttp") + + self.host = host + self.port = port + self.endpoint = endpoint + self._session: Optional[aiohttp.ClientSession] = None + self._response: Optional[aiohttp.ClientResponse] = None + self._connected = False + + @asynccontextmanager + async def connect(self): + """Establish SSE connection""" + try: + self._session = aiohttp.ClientSession() + url = f"http://{self.host}:{self.port}{self.endpoint}" + + print(f"Connecting to SSE endpoint: {url}") + self._response = await self._session.get(url) + + if self._response.status != 200: + raise ConnectionError(f"SSE connection failed with status {self._response.status}") + + self._connected = True + yield self + + except Exception as e: + print(f"SSE connection error: {e}") + raise + finally: + await self.close() + + async def send_message(self, message: Dict[str, Any]) -> None: + """Send a message through SSE transport""" + if not self._connected: + raise ConnectionError("SSE transport not connected") + + # SSE is typically unidirectional from server to client + # For bidirectional communication, you might need to use a different approach + # or combine SSE with HTTP POST requests + print(f"SSE Transport: Sending message: {message}") + + # TODO: Implement actual message sending logic + # This might require HTTP POST to a separate endpoint + # or WebSocket upgrade, depending on server implementation + + async def receive_message(self) -> Dict[str, Any]: + """Receive a message through SSE transport""" + if not self._connected or not self._response: + raise ConnectionError("SSE transport not connected") + + # Read SSE data + async for line in self._response.content: + line_str = line.decode('utf-8').strip() + + if line_str.startswith('data: '): + data = line_str[6:] # Remove 'data: ' prefix + if data: + try: + return json.loads(data) + except json.JSONDecodeError: + continue + + return {} + + async def close(self) -> None: + """Close the SSE connection""" + if self._response: + self._response.close() + if self._session: + await self._session.close() + + self._connected = False + print("SSE transport closed") diff --git a/build/lib/mcp_template/transport/stdio_transport.py b/build/lib/mcp_template/transport/stdio_transport.py new file mode 100644 index 0000000..d480f6b --- /dev/null +++ b/build/lib/mcp_template/transport/stdio_transport.py @@ -0,0 +1,81 @@ +""" +STDIO Transport Implementation +""" +import asyncio +import json +import sys +from typing import Dict, Any, Optional +from contextlib import asynccontextmanager + +from ..core.interfaces import IMCPTransport + + +class STDIOTransport(IMCPTransport): + """STDIO transport for MCP communication""" + + def __init__(self, **kwargs): + self._connected = False + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + + @asynccontextmanager + async def connect(self): + """Establish STDIO connection""" + try: + # Use stdin/stdout for communication + self._reader = asyncio.StreamReader() + reader_protocol = asyncio.StreamReaderProtocol(self._reader) + await asyncio.get_event_loop().connect_read_pipe( + lambda: reader_protocol, sys.stdin + ) + + # For writing, we'll use stdout + self._writer = None # We'll write directly to stdout + + self._connected = True + print("STDIO transport connected", file=sys.stderr) + yield self + + except Exception as e: + print(f"STDIO connection error: {e}", file=sys.stderr) + raise + finally: + await self.close() + + async def send_message(self, message: Dict[str, Any]) -> None: + """Send a message through STDIO""" + if not self._connected: + raise ConnectionError("STDIO transport not connected") + + # Convert message to JSON and send to stdout + message_json = json.dumps(message, separators=(',', ':')) + print(message_json, flush=True) + + async def receive_message(self) -> Dict[str, Any]: + """Receive a message through STDIO""" + if not self._connected or not self._reader: + raise ConnectionError("STDIO transport not connected") + + try: + # Read line from stdin + line = await self._reader.readline() + if not line: + return {} # EOF + + line_str = line.decode('utf-8').strip() + if line_str: + return json.loads(line_str) + + except json.JSONDecodeError as e: + print(f"Invalid JSON received: {e}", file=sys.stderr) + except Exception as e: + print(f"Error receiving message: {e}", file=sys.stderr) + + return {} + + async def close(self) -> None: + """Close the STDIO connection""" + self._connected = False + if self._writer: + self._writer.close() + print("STDIO transport closed", file=sys.stderr) diff --git a/build/lib/mcp_template/transport/transport_manager.py b/build/lib/mcp_template/transport/transport_manager.py new file mode 100644 index 0000000..ff5f051 --- /dev/null +++ b/build/lib/mcp_template/transport/transport_manager.py @@ -0,0 +1,81 @@ +""" +Transport Manager for easy switching between transport methods +""" +from typing import Optional, Union +from contextlib import asynccontextmanager + +from ..core.types import TransportType +from ..core.interfaces import IMCPTransport +from .sse_transport import SSETransport +from .stdio_transport import STDIOTransport + + +class TransportManager: + """Manager class for handling different MCP transport methods""" + + def __init__(self): + self._current_transport: Optional[IMCPTransport] = None + + @asynccontextmanager + async def get_transport( + self, + transport_type: Union[str, TransportType], + **kwargs + ): + """Get a transport instance for the specified type""" + + # Convert string to enum if needed + if isinstance(transport_type, str): + transport_type = TransportType(transport_type.lower()) + + # Create the appropriate transport + if transport_type == TransportType.SSE: + transport = SSETransport(**kwargs) + elif transport_type == TransportType.STDIO: + transport = STDIOTransport(**kwargs) + else: + raise ValueError(f"Unsupported transport type: {transport_type}") + + self._current_transport = transport + + try: + async with transport.connect(): + yield transport + finally: + self._current_transport = None + + async def switch_transport( + self, + new_transport_type: Union[str, TransportType], + **kwargs + ) -> IMCPTransport: + """Switch to a different transport type""" + + # Close current transport if exists + if self._current_transport: + await self._current_transport.close() + + # Convert string to enum if needed + if isinstance(new_transport_type, str): + new_transport_type = TransportType(new_transport_type.lower()) + + # Create new transport + if new_transport_type == TransportType.SSE: + self._current_transport = SSETransport(**kwargs) + elif new_transport_type == TransportType.STDIO: + self._current_transport = STDIOTransport(**kwargs) + else: + raise ValueError(f"Unsupported transport type: {new_transport_type}") + + return self._current_transport + + @property + def current_transport(self) -> Optional[IMCPTransport]: + """Get the currently active transport""" + return self._current_transport + + async def close_current_transport(self) -> None: + """Close the currently active transport""" + if self._current_transport: + await self._current_transport.close() + self._current_transport = None diff --git a/config.py b/config.py new file mode 100644 index 0000000..0c20744 --- /dev/null +++ b/config.py @@ -0,0 +1,9 @@ +from dotenv import load_dotenv +import os +load_dotenv() +class Config: + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY") + GROK_API_KEY = os.getenv("GROK_API_KEY") + ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") + GROQ_API_KEY = os.getenv("GROQ_API_KEY") \ No newline at end of file diff --git a/dev_run.py b/dev_run.py new file mode 100755 index 0000000..40ea589 --- /dev/null +++ b/dev_run.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +MCP Development Server Runner +Optimized for use with: mcp dev dev_run.py + +This file is specifically designed for MCP development workflow. +It automatically starts the server with SSE transport on port 3000. +""" + +import sys +import os +import logging + +# Configure logging to reduce noise +logging.getLogger("mcp").setLevel(logging.WARNING) +logging.getLogger("uvicorn").setLevel(logging.WARNING) +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + +# Add the src directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from mcp_template.server.modular_server import create_default_server + +# Create and expose the MCP server for dev mode +# This is what mcp dev command looks for +print("Initializing MCP Development Server...") +print("SSE Transport on port 3000") + +# Create the server with dev settings +server = create_default_server() + +# Extract the FastMCP instance for MCP dev command compatibility +mcp = server.mcp + + +if __name__ == "__main__": + mcp.run() + +# print(" MCP server ready for development!") +# print("Use: mcp dev dev_run.py") +# print("Server will be available at: http://0.0.0.0:3000/sse") +#uv run mcp dev dev_run.py +# The MCP dev command will handle running the server +# We just need to make the mcp object available diff --git a/examples/demo_client.py b/examples/demo_client.py new file mode 100644 index 0000000..6339492 --- /dev/null +++ b/examples/demo_client.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +MCP Template Demo Client +Demonstrates how to connect to and interact with MCP servers +""" + +import asyncio +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from clients.client_factory import AIClientFactory +from transport.transport_manager import TransportManager +from core.types import TransportType + + +class MCPDemoClient: + """Demo client for interacting with MCP servers""" + + def __init__(self, ai_provider="openai", model="gpt-4o"): + self.ai_provider = ai_provider + self.model = model + self.ai_client = None + self.transport_manager = TransportManager() + + async def initialize(self): + """Initialize the demo client""" + print(f"🤖 Initializing {self.ai_provider} client with {self.model}...") + + # Initialize AI client + self.ai_client = AIClientFactory.create_client( + provider=self.ai_provider, + model_name=self.model + ) + + await self.ai_client.initialize() + print("✅ AI client initialized") + + async def demo_stdio_interaction(self): + """Demo direct STDIO interaction with MCP server""" + print("\n🔧 Demo: Direct STDIO Interaction") + print("This demonstrates direct tool calls without AI involvement") + + # For this demo, we'll simulate MCP server responses + # In a real scenario, you'd connect to an actual MCP server + + print("📋 Available tools (simulated):") + print(" • add: Add two numbers together") + print(" • multiply: Multiply two numbers") + print(" • greet_user: Generate personalized greeting") + print(" • calculate_bmi: Calculate BMI and health category") + + # Simulate some tool calls + print("\n🧮 Simulating tool calls:") + + # Simulate add tool + print(" add(5, 3) = 8") + + # Simulate greet tool + print(" greet_user('Alice', 'casual') = 'Hey Alice! Welcome aboard! 🎉'") + + # Simulate BMI tool + print(" calculate_bmi(70, 1.75) = 'Your BMI is 22.9 (Normal weight)'") + + async def demo_ai_with_tools(self): + """Demo AI client with MCP tool integration""" + print("\n🧠 Demo: AI Client with MCP Tools") + print("This demonstrates how AI can use MCP tools to perform tasks") + + # Sample queries that would benefit from MCP tools + queries = [ + "What is 15 + 27?", + "Can you greet Sarah in a professional manner?", + "What's the BMI for someone who weighs 80kg and is 1.8m tall?", + "Calculate 12 * 8 for me" + ] + + print("💭 Sample queries for AI + MCP integration:") + for i, query in enumerate(queries, 1): + print(f" {i}. {query}") + + print("\n📝 Note: To run actual AI+MCP integration, you need:") + print(" 1. A running MCP server (see demo_server.py)") + print(" 2. Proper API keys in environment variables") + print(" 3. Network connection for AI provider") + + async def demo_transport_switching(self): + """Demo transport layer switching capabilities""" + print("\n🔄 Demo: Transport Layer Switching") + print("The MCP template supports easy switching between transports:") + + print("📡 SSE (Server-Sent Events):") + print(" • Best for: Web applications, remote connections") + print(" • Protocol: HTTP-based, real-time communication") + print(" • Use case: Browser-based MCP clients") + + print("\n💻 STDIO (Standard Input/Output):") + print(" • Best for: Local applications, direct process communication") + print(" • Protocol: Direct pipes between processes") + print(" • Use case: CLI tools, local development") + + print("\n🔧 Easy switching example:") + print(" # Switch to SSE transport") + print(" transport_manager.switch_transport('sse', host='localhost', port=8050)") + print(" ") + print(" # Switch to STDIO transport") + print(" transport_manager.switch_transport('stdio')") + + async def demo_configuration(self): + """Demo configuration management""" + print("\n⚙️ Demo: Configuration Management") + print("The MCP template supports flexible configuration:") + + print("📄 Configuration sources (in order of priority):") + print(" 1. Environment variables") + print(" 2. JSON configuration files") + print(" 3. Default values") + + print("\n🌍 Environment variables example:") + print(" MCP_SERVER_NAME=MyServer") + print(" MCP_TRANSPORT=sse") + print(" MCP_AI_PROVIDER=openai") + print(" OPENAI_API_KEY=your_key_here") + + print("\n📋 JSON config example:") + print(" {") + print(' "server": {"name": "MyServer", "port": 8080},') + print(' "client": {"provider": "openai", "model": "gpt-4o"}') + print(" }") + + async def run_demo(self): + """Run the complete demo""" + print("🎭 MCP Template Demo Client") + print("=" * 50) + + await self.initialize() + await self.demo_stdio_interaction() + await self.demo_ai_with_tools() + await self.demo_transport_switching() + await self.demo_configuration() + + print("\n🎉 Demo completed!") + print("\n📚 Next steps:") + print(" • Run 'python examples/demo_server.py calculator' for a calculator server") + print(" • Run 'python examples/demo_server.py knowledge' for a knowledge base server") + print(" • Check the tests/ directory for comprehensive test examples") + print(" • Read docs in README.md for detailed usage instructions") + + +async def main(): + """Main demo function""" + if len(sys.argv) > 1: + ai_provider = sys.argv[1] + model = sys.argv[2] if len(sys.argv) > 2 else None + else: + ai_provider = "openai" + model = "gpt-4o" + + print(f"🤖 Using AI provider: {ai_provider}") + if model: + print(f"🧠 Using model: {model}") + + client = MCPDemoClient(ai_provider, model) + + try: + await client.run_demo() + except KeyboardInterrupt: + print("\n👋 Demo interrupted by user") + except Exception as e: + print(f"❌ Error running demo: {e}") + print("💡 Make sure you have the required dependencies installed:") + print(" pip install openai aiohttp python-dotenv") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/demo_server.py b/examples/demo_server.py new file mode 100644 index 0000000..4d57690 --- /dev/null +++ b/examples/demo_server.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +MCP Template Demo Server +Demonstrates how to create and run different types of MCP servers +""" + +import asyncio +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from server.server_factory import MCPServerFactory + + +async def demo_calculator_server(): + """Demo: Create and run a calculator server with SSE transport""" + print("🚀 Starting Calculator Server with SSE transport...") + + server = MCPServerFactory.create_basic_calculator_server( + name="Demo Calculator Server", + transport="sse", + host="localhost", + port=8050 + ) + + print("📊 Available tools:") + tools = await server.list_tools() + for tool in tools: + print(f" • {tool['name']}: {tool['description']}") + + print("🌐 Server will be available at: http://localhost:8050/sse") + print("💡 To test: Run 'python examples/demo_client.py' in another terminal") + + await server.start() + + +async def demo_knowledge_base_server(): + """Demo: Create and run a knowledge base server with STDIO transport""" + print("🚀 Starting Knowledge Base Server with STDIO transport...") + + # Custom knowledge base data + kb_data = { + "company_info": "TechCorp is a leading AI solutions provider founded in 2020.", + "mission": "Our mission is to democratize AI through open-source tools and education.", + "values": "Innovation, transparency, collaboration, and ethical AI development.", + "contact": "Email: info@techcorp.com | Phone: (555) 123-4567" + } + + server = MCPServerFactory.create_knowledge_base_server( + name="Demo Knowledge Base Server", + transport="stdio", + kb_data=kb_data + ) + + print("📚 Available tools:") + tools = await server.list_tools() + for tool in tools: + print(f" • {tool['name']}: {tool['description']}") + + print("💻 Server running in STDIO mode") + print("💡 To test: Run 'python examples/demo_client.py' in another terminal") + + await server.start() + + +async def demo_custom_server(): + """Demo: Create a custom server with mixed tools and resources""" + print("🚀 Starting Custom Server with mixed capabilities...") + + # Custom tools + async def greet_user(name: str, style: str = "formal") -> str: + """Generate a personalized greeting""" + if style == "casual": + return f"Hey {name}! Welcome aboard! 🎉" + elif style == "professional": + return f"Good day, {name}. Welcome to our platform." + else: + return f"Hello, {name}. Welcome!" + + async def calculate_bmi(weight_kg: float, height_m: float) -> str: + """Calculate BMI and provide health category""" + bmi = weight_kg / (height_m ** 2) + + if bmi < 18.5: + category = "Underweight" + elif bmi < 25: + category = "Normal weight" + elif bmi < 30: + category = "Overweight" + else: + category = "Obese" + + return f"Your BMI is {bmi:.1f} ({category})" + + from core.types import MCPTool + + tools = [ + MCPTool( + name="greet_user", + description="Generate a personalized greeting with different styles", + input_schema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "User's name"}, + "style": { + "type": "string", + "enum": ["casual", "formal", "professional"], + "description": "Greeting style" + } + }, + "required": ["name"] + }, + handler=greet_user + ), + MCPTool( + name="calculate_bmi", + description="Calculate BMI and provide health assessment", + input_schema={ + "type": "object", + "properties": { + "weight_kg": {"type": "number", "description": "Weight in kilograms"}, + "height_m": {"type": "number", "description": "Height in meters"} + }, + "required": ["weight_kg", "height_m"] + }, + handler=calculate_bmi + ) + ] + + server = MCPServerFactory.create_server( + name="Demo Custom Server", + transport="stdio", + tools=tools + ) + + print("🛠️ Available tools:") + tools_list = await server.list_tools() + for tool in tools_list: + print(f" • {tool['name']}: {tool['description']}") + + print("💻 Server running in STDIO mode") + print("💡 To test: Run 'python examples/demo_client.py' in another terminal") + + await server.start() + + +async def main(): + """Main demo function""" + if len(sys.argv) != 2: + print("Usage: python demo_server.py ") + print("Available server types:") + print(" calculator - Basic calculator with math operations") + print(" knowledge - Knowledge base with searchable content") + print(" custom - Custom server with mixed capabilities") + sys.exit(1) + + server_type = sys.argv[1].lower() + + try: + if server_type == "calculator": + await demo_calculator_server() + elif server_type == "knowledge": + await demo_knowledge_base_server() + elif server_type == "custom": + await demo_custom_server() + else: + print(f"Unknown server type: {server_type}") + sys.exit(1) + + except KeyboardInterrupt: + print("\n👋 Server stopped by user") + except Exception as e: + print(f"❌ Error running server: {e}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/modular_demo.py b/examples/modular_demo.py new file mode 100644 index 0000000..4d2e68e --- /dev/null +++ b/examples/modular_demo.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Modular MCP Template Demo +Demonstrates the new modular architecture with separate tool, prompt, and resource folders +""" + +import asyncio +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from server.server_factory import MCPServerFactory +from tools.tool_registry import ToolRegistry +from resources.data_resources import DataResources +from clients.client_factory import AIClientFactory + + +async def demo_modular_server_creation(): + """Demo: Creating servers using the modular architecture""" + print("🏗️ Modular MCP Server Creation Demo") + print("=" * 50) + + # 1. Using Tool Registry for category-based tool selection + print("\n1️⃣ Tool Registry - Category-based Tool Selection") + registry = ToolRegistry() + + print("Available tool categories:") + categories = registry.get_available_categories() + for category in categories: + tools = registry.get_tools_by_category(category) + print(f" • {category}: {len(tools)} tools") + + # Create a math-focused server + math_tools = registry.get_tools_by_category('math') + math_server = MCPServerFactory.create_server( + name="Math Server", + tools=math_tools, + transport="stdio" + ) + print(f"\n✅ Created Math Server with {len(math_tools)} tools") + + # 2. Using Server Factory with categories + print("\n2️⃣ Server Factory - Direct Category Selection") + dev_server = MCPServerFactory.create_server_from_categories( + name="Developer Server", + categories=["math", "text", "system"], + transport="stdio" + ) + print("✅ Created Developer Server with math, text, and system tools") + + # 3. Adding custom tools to registry + print("\n3️⃣ Custom Tools - Extending the Registry") + async def generate_password(length: int = 12) -> str: + """Generate a secure random password""" + import secrets + import string + chars = string.ascii_letters + string.digits + "!@#$%^&*" + return ''.join(secrets.choice(chars) for _ in range(length)) + + from core.types import MCPTool + custom_tool = MCPTool( + name="generate_password", + description="Generate a secure random password", + input_schema={ + "type": "object", + "properties": { + "length": {"type": "integer", "description": "Password length", "default": 12} + } + }, + handler=generate_password, + ) + + registry.add_custom_tool(custom_tool) + print("✅ Added custom password generation tool") + + # 4. Resources integration + print("\n4️⃣ Resources - Static Data Integration") + resources = DataResources.get_resources() + print(f"Available data resources: {len(resources)}") + for resource in resources: + print(f" • {resource.name}: {resource.uri}") + + # Create server with resources + resource_server = MCPServerFactory.create_server( + name="Resource Server", + tools=math_tools, + resources=resources, + transport="stdio" + ) + print("✅ Created server with both tools and resources") + + # 5. Test the servers + print("\n5️⃣ Server Testing") + print("Testing Math Server:") + + # List tools + tools = await math_server.list_tools() + print(f" 📋 Available tools: {[t['name'] for t in tools]}") + + # Test a tool + try: + result = await math_server.call_tool("add", {"a": 15, "b": 27}) + print(f" 🧮 add(15, 27) = {result}") + except Exception as e: + print(f" ❌ Tool test failed: {e}") + + # Test resources + print("\nTesting Resource Server:") + resources_list = await resource_server.list_resources() + print(f" 📄 Available resources: {len(resources_list)}") + + if resources_list: + # Read a resource + resource_content = await resource_server.read_resource(resources_list[0]["uri"]) + print(f" 📖 Read resource: {resources_list[0]['name']} ({len(resource_content)} chars)") + + print("\n✅ Modular server creation demo completed!") + + +async def demo_ai_client_integration(): + """Demo: AI client integration with modular servers""" + print("\n🤖 AI Client Integration Demo") + print("=" * 40) + + try: + # Note: This demo requires API keys to be set + print("Note: This demo requires OPENAI_API_KEY environment variable") + + # Create AI client (would work with proper API key) + ai_client = AIClientFactory.create_openai_client( + model_name="gpt-4o", + api_key=os.getenv("OPENAI_API_KEY", "demo-key") + ) + print("✅ Created OpenAI client (requires valid API key for actual use)") + + # Create a server for AI integration + registry = ToolRegistry() + tools = registry.get_tools_by_categories(["math", "text"]) + + server = MCPServerFactory.create_server( + name="AI Integration Server", + tools=tools, + transport="stdio" + ) + print(f"✅ Created server with {len(tools)} tools for AI integration") + + print("\nExample AI queries that would work:") + print(" • 'What is 25 * 18?'") + print(" • 'Count the words in: The quick brown fox jumps over the lazy dog'") + print(" • 'Convert this to uppercase: hello world'") + print(" • 'Calculate BMI for 70kg and 1.75m height'") + + except Exception as e: + print(f"❌ AI Client demo setup failed: {e}") + print("This is expected without proper API key configuration") + + +async def main(): + """Main demo function""" + print("🎭 Modular MCP Template Demo") + print("Demonstrating the new modular architecture with separate folders") + print("=" * 70) + + try: + await demo_modular_server_creation() + await demo_ai_client_integration() + + print("\n🎉 All demos completed successfully!") + print("\n📚 Key Features Demonstrated:") + print(" • 🔧 Modular tool organization by category") + print(" • 📄 Separate resource management") + print(" • 🏗️ Flexible server creation with tool registry") + print(" • 🤖 AI client abstraction and integration") + print(" • ⚙️ Easy configuration and customization") + + print("\n🚀 Next Steps:") + print(" • Add your API keys to .env file") + print(" • Create custom tools in src/tools/") + print(" • Add custom resources in src/resources/") + print(" • Create custom prompts in src/prompts/") + print(" • Run the examples with: python examples/modular_demo.py") + + except KeyboardInterrupt: + print("\n👋 Demo interrupted by user") + except Exception as e: + print(f"❌ Error running demo: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/modular_server_example.py b/examples/modular_server_example.py new file mode 100644 index 0000000..93e34b8 --- /dev/null +++ b/examples/modular_server_example.py @@ -0,0 +1,47 @@ +""" +Modular Server Example +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from mcp_template.server.modular_server import ModularMCPServer + + +def main(): + """Run the modular server example""" + # Create a modular server + server = ModularMCPServer( + name="Modular Demo Server", + host="0.0.0.0", + port=8050, + stateless_http=True + ) + + # Print server info + print("Server Information:") + print("=" * 50) + info = server.get_server_info() + print(f"Name: {info['name']}") + print(f"Host: {info['host']}:{info['port']}") + print(f"Tools: {info['tools']['count']} ({', '.join(info['tools']['names'])})") + print(f"Prompts: {info['prompts']['count']} ({', '.join(info['prompts']['names'])})") + print(f"Resources: {info['resources']['count']} ({', '.join(info['resources']['uris'])})") + print("=" * 50) + + # Choose transport + transport = "stdio" # Change to "sse" for HTTP transport + + if transport == "stdio": + print("Running server with STDIO transport") + print("Connect using MCP client with stdio transport") + elif transport == "sse": + print(f"Running server with SSE transport on http://{server.host}:{server.port}") + print("Connect using MCP client with SSE transport") + + # Run the server + server.run(transport=transport) + + +if __name__ == "__main__": + main() diff --git a/examples/test_llm_config.py b/examples/test_llm_config.py new file mode 100644 index 0000000..6c73e11 --- /dev/null +++ b/examples/test_llm_config.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Simple test script for LLM clients using config.py +""" +import asyncio +import sys +import os + +# Add the src directory to the path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from config import Config +from llm_client.client_factory import AIClientFactory + + +async def test_openai_client(): + """Test OpenAI client with config.py""" + print("Testing OpenAI client with config.py...") + + if not Config.OPENAI_API_KEY: + print("⚠️ OPENAI_API_KEY not found in environment variables") + return + + try: + # Create OpenAI client using the factory (API key automatically loaded from config) + client = AIClientFactory.create_openai_client( + model_name="gpt-3.5-turbo" # Use cheaper model for testing + ) + + # Test basic chat completion + messages = [{"role": "user", "content": "Hello! Please respond with just 'Hello from OpenAI!'"}] + response = await client.chat_completion(messages) + + print("✅ OpenAI client test successful!") + print(f"Response: {response['choices'][0]['message']['content']}") + + await client.cleanup() + + except Exception as e: + print(f"❌ OpenAI client test failed: {e}") + + +async def test_client_factory(): + """Test client factory functionality""" + print("\nTesting client factory...") + + # Test available providers + providers = AIClientFactory.get_available_providers() + print(f"Available providers: {providers}") + + # Test provider validation + print(f"OpenAI valid: {AIClientFactory.validate_provider('openai')}") + print(f"Invalid provider valid: {AIClientFactory.validate_provider('invalid')}") + + # Test creating client by provider (API key loaded automatically) + if Config.OPENAI_API_KEY: + try: + client = AIClientFactory.create_client("openai", "gpt-3.5-turbo") + print("✅ Client creation by provider successful!") + await client.cleanup() + except Exception as e: + print(f"❌ Client creation by provider failed: {e}") + else: + print("⚠️ Skipping client creation test - no OpenAI API key") + + print("✅ Client factory test completed!") + + +async def test_config_loading(): + """Test config.py loading""" + print("\nTesting config.py loading...") + + print(f"OpenAI API Key loaded: {'Yes' if Config.OPENAI_API_KEY else 'No'}") + print(f"Claude API Key loaded: {'Yes' if Config.CLAUDE_API_KEY else 'No'}") + print(f"Grok API Key loaded: {'Yes' if Config.GROK_API_KEY else 'No'}") + + print("✅ Config loading test completed!") + + +async def main(): + """Run all tests""" + print("🚀 Starting LLM Client Tests with config.py") + print("=" * 50) + + await test_config_loading() + await test_client_factory() + await test_openai_client() + + print("\n" + "=" * 50) + print("🏁 All tests completed!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/images/mcp_intro.png b/images/mcp_intro.png new file mode 100644 index 0000000..6767f36 Binary files /dev/null and b/images/mcp_intro.png differ diff --git a/images/steup_mechanism.png b/images/steup_mechanism.png new file mode 100644 index 0000000..09e19a4 Binary files /dev/null and b/images/steup_mechanism.png differ diff --git a/intro_test/.env b/intro_test/.env new file mode 100644 index 0000000..113dd37 --- /dev/null +++ b/intro_test/.env @@ -0,0 +1 @@ +OPENAI_API_KEY=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 \ No newline at end of file diff --git a/intro_test/.gitignore b/intro_test/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/intro_test/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/intro_test/.python-version b/intro_test/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/intro_test/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/intro_test/Config.py b/intro_test/Config.py new file mode 100644 index 0000000..2363684 --- /dev/null +++ b/intro_test/Config.py @@ -0,0 +1,5 @@ +from dotenv import load_dotenv +import os +load_dotenv() +class Config: + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") \ No newline at end of file diff --git a/intro_test/INTRO_README.md b/intro_test/INTRO_README.md new file mode 100644 index 0000000..d0983a0 --- /dev/null +++ b/intro_test/INTRO_README.md @@ -0,0 +1,295 @@ +# Model Context Protocol (MCP) Developer Guide + +## What is MCP? + +MCP (Model Context Protocol) is an open-source standard for connecting AI applications to external systems. Using MCP, AI applications like Claude or ChatGPT can connect to data sources (local files, databases), tools (search engines, calculators), and workflows (specialized prompts)—enabling them to access key information and perform tasks. + +Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect electronic devices, MCP provides a standardized way to connect AI applications to external systems. + +## MCP Overview + +![MCP Introduction](images/mcp_intro.png) + +The diagram above illustrates how MCP creates a standardized interface between AI models and external tools/data sources through MCP servers that act as intermediaries. + +## Core MCP Concepts + +MCP servers can provide three main types of capabilities that enable AI applications to interact with external systems: + +### 🔧 Tools +**Functions that can be called by the LLM (with user approval)** + +Tools are the primary way AI models perform actions and computations. They represent executable functions that the AI can invoke to: + +- **Perform calculations** (like our calculator example) +- **Execute system commands** +- **Query databases** +- **Interact with APIs** +- **Process data** + +**Example Implementation:** +```python +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers together""" + return a + b +``` + +**Key Characteristics:** +- Require explicit user approval before execution +- Can have complex input/output schemas +- Enable AI to perform real-world actions +- Primary focus of this tutorial + +### 📄 Resources +**File-like data that can be read by clients** + +Resources represent data sources that AI can access and read, similar to files or API responses: + +- **File contents** (local or remote files) +- **Database records** +- **API responses** +- **Configuration data** +- **Log files** + +**Example Use Cases:** +- Reading documentation files +- Accessing configuration settings +- Querying data from databases +- Retrieving API responses + +### 📝 Prompts +**Pre-written templates that help users accomplish specific tasks** + +Prompts are reusable templates that guide AI behavior for specific scenarios: + +- **Task-specific instructions** +- **Workflow templates** +- **Specialized conversation starters** +- **Domain-specific guidance** + +**Example Applications:** +- Code review templates +- Customer service response formats +- Technical writing guidelines +- Analysis frameworks + +## Transport Protocols + +MCP supports multiple transport protocols for communication between clients and servers: + +### 1. Server-Sent Events (SSE) +- **Use Case**: Web-based applications, remote connections +- **Communication**: HTTP-based, unidirectional from server to client +- **Implementation**: Uses HTTP endpoints for real-time communication +- **Port**: Configurable (default: 8050) + +### 2. Standard Input/Output (STDIO) +- **Use Case**: Local applications, direct process communication +- **Communication**: Direct process pipes, bidirectional +- **Implementation**: Launches server as subprocess with stdin/stdout pipes +- **Setup**: No network configuration required + +![Setup Mechanism](images/steup_mechanism.png) + +The setup mechanism diagram shows how different transport protocols handle the connection establishment between MCP clients and servers. + +## Project Structure + +This repository contains example implementations demonstrating MCP concepts: + +### `intro_test/` - Basic MCP Implementation + +This folder provides fundamental MCP server and client examples: + +- **`server.py`** - MCP server with calculator tool using FastMCP +- **`client_sse.py`** - Client connecting via SSE transport +- **`client_stdio.py`** - Client connecting via STDIO transport +- **`main.py`** - Main entry point for testing +- **`Config.py`** - Configuration management + +**Key Features:** +- Simple calculator tool (`add` function) +- Support for both SSE and STDIO transports +- Environment variable configuration +- FastMCP framework usage + +### `intro_test/openai_test/` - OpenAI Integration + +This folder demonstrates MCP integration with OpenAI: + +- **`client.py`** - MCP-OpenAI client class with tool calling +- **`server.py`** - MCP server for the integration example +- **`data/kb.json`** - Knowledge base data for AI responses + +**Key Features:** +- ✅ Seamless OpenAI API integration +- ✅ Automatic tool discovery and execution +- ✅ Conversation management with tool results +- ✅ Error handling and cleanup +- ✅ Knowledge base tool with JSON data integration + +## Quick Start + +### Prerequisites + +- Python 3.13+ +- OpenAI API key (for OpenAI integration examples) + +### Installation + +```bash +# Clone the repository +git clone +cd mcp_template + +# Navigate to intro_test directory +cd intro_test + +# Install dependencies using uv (recommended) +uv sync + +# Or using pip (if you prefer) +pip install -e . +``` + +### Environment Setup + +Create a `.env` file in the project root: + +```bash +# Create .env file +touch .env +``` + +Then add your OpenAI API key to the `.env` file: + +```env +OPENAI_API_KEY=your_openai_api_key_here +``` + +**Note:** Get your API key from [OpenAI Platform](https://platform.openai.com/api-keys) + +### Running Basic Examples + +1. **Start the MCP Server (SSE Transport):** +```bash +cd intro_test +uv run server.py +``` + +2. **Run the SSE Client:** +```bash +uv run client_sse.py +``` + +3. **Run the STDIO Client:** +```bash +uv run client_stdio.py +``` + +### OpenAI Integration Example + +1. **Start the MCP Server:** +```bash +cd intro_test +uv run python openai_test/server.py +``` + +2. **Run the OpenAI Client:** +```bash +cd intro_test +uv run python openai_test/client.py +``` + +**Note:** The OpenAI integration examples must be run from the `intro_test` directory due to relative import dependencies. + +## Understanding the Code + +### Server Implementation + +The server uses FastMCP framework to create MCP-compatible servers: + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP( + name="Calculator", + host="0.0.0.0", + port=8050, + stateless_http=True, +) + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers together""" + return a + b + +# Run with different transports +mcp.run(transport="sse") # or "stdio" or "streamable-http" +``` + +### Client Implementation + +Clients connect to servers using different transport methods: + +**SSE Client:** +```python +async with sse_client("http://localhost:8050/sse") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + # Use tools... +``` + +**STDIO Client:** +```python +server_params = StdioServerParameters( + command="python", + args=["server.py"], +) +async with stdio_client(server_params) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Use tools... +``` + +### OpenAI Integration + +The OpenAI client bridges MCP tools with OpenAI's function calling: + +```python +# Get MCP tools in OpenAI format +tools = await self.get_mcp_tools() + +# Make OpenAI API call with tools +response = await self.openai_client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": query}], + tools=tools, + tool_choice="auto", +) +``` + +## Next Steps + +This guide covers the fundamentals of MCP development. The examples in this repository demonstrate: + +- ✅ Basic server and client setup +- ✅ Multiple transport protocol implementations +- ✅ OpenAI integration patterns +- ✅ Tool definition and execution +- ✅ Error handling and cleanup + +Future enhancements may include: +- Additional transport protocols +- Advanced tool implementations +- Database integrations +- Webhook support +- Authentication mechanisms + +--- + +*This documentation will be updated as new features and examples are added to the repository.* + + + +URL: https://modelcontextprotocol.io/docs/learn/architecture \ No newline at end of file diff --git a/intro_test/client_sse.py b/intro_test/client_sse.py new file mode 100644 index 0000000..9ad5401 --- /dev/null +++ b/intro_test/client_sse.py @@ -0,0 +1,38 @@ +import asyncio +import nest_asyncio +from mcp import ClientSession +from mcp.client.sse import sse_client + +nest_asyncio.apply() # Needed to run interactive python + +""" +Make sure: +1. The server is running before running this script. +2. The server is configured to use SSE transport. +3. The server is listening on port 8050. + +To run the server: +uv run server.py +""" + + +async def main(): + # Connect to the server using SSE + async with sse_client("http://localhost:8050/sse") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + + # List available tools + tools_result = await session.list_tools() + print("Available tools:") + for tool in tools_result.tools: + print(f" - {tool.name}: {tool.description}") + + # Call our calculator tool + result = await session.call_tool("add", arguments={"a": 2, "b": 3}) + print(f"2 + 3 = {result.content[0].text}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/intro_test/client_stdio.py b/intro_test/client_stdio.py new file mode 100644 index 0000000..0234417 --- /dev/null +++ b/intro_test/client_stdio.py @@ -0,0 +1,34 @@ +import asyncio +import nest_asyncio +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +nest_asyncio.apply() # Needed to run interactive python + + +async def main(): + # Define server parameters + server_params = StdioServerParameters( + command="python", # The command to run your server + args=["server.py"], # Arguments to the command + ) + + # Connect to the server + async with stdio_client(server_params) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + + # List available tools + tools_result = await session.list_tools() + print("Available tools:") + for tool in tools_result.tools: + print(f" - {tool.name}: {tool.description}") + + # Call our calculator tool + result = await session.call_tool("add", arguments={"a": 2, "b": 3}) + print(f"2 + 3 = {result.content[0].text}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/intro_test/main.py b/intro_test/main.py new file mode 100644 index 0000000..8b7fb96 --- /dev/null +++ b/intro_test/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from intro-test!") + + +if __name__ == "__main__": + main() diff --git a/intro_test/openai_test/client.py b/intro_test/openai_test/client.py new file mode 100644 index 0000000..a46201a --- /dev/null +++ b/intro_test/openai_test/client.py @@ -0,0 +1,176 @@ +import asyncio +import json +import sys +import os +from contextlib import AsyncExitStack +from typing import Any, Dict, List, Optional + +import nest_asyncio +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from openai import AsyncOpenAI + +# Add parent directory to path to import Config +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from Config import Config + +# Apply nest_asyncio to allow nested event loops (needed for Jupyter/IPython) +nest_asyncio.apply() + + + +class MCPOpenAIClient: + """Client for interacting with OpenAI models using MCP tools.""" + + def __init__(self, model: str = "gpt-4o"): + """Initialize the OpenAI MCP client. + + Args: + model: The OpenAI model to use. + """ + # Initialize session and client objects + self.session: Optional[ClientSession] = None + self.exit_stack = AsyncExitStack() + self.openai_client = AsyncOpenAI(api_key=Config.OPENAI_API_KEY) + self.model = model + self.stdio: Optional[Any] = None + self.write: Optional[Any] = None + + async def connect_to_server(self, server_script_path: str = "server.py"): + """Connect to an MCP server. + + Args: + server_script_path: Path to the server script. + """ + # Server configuration + server_params = StdioServerParameters( + command="python", + args=[server_script_path], + ) + + # Connect to the server + stdio_transport = await self.exit_stack.enter_async_context( + stdio_client(server_params) + ) + self.stdio, self.write = stdio_transport + self.session = await self.exit_stack.enter_async_context( + ClientSession(self.stdio, self.write) + ) + + # Initialize the connection + await self.session.initialize() + + # List available tools + tools_result = await self.session.list_tools() + print("\nConnected to server with tools:") + for tool in tools_result.tools: + print(f" - {tool.name}: {tool.description}") + + async def get_mcp_tools(self) -> List[Dict[str, Any]]: + """Get available tools from the MCP server in OpenAI format. + + Returns: + A list of tools in OpenAI format. + """ + tools_result = await self.session.list_tools() + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.inputSchema, + }, + } + for tool in tools_result.tools + ] + + async def process_query(self, query: str) -> str: + """Process a query using OpenAI and available MCP tools. + + Args: + query: The user query. + + Returns: + The response from OpenAI. + """ + # Get available tools + tools = await self.get_mcp_tools() + + # Initial OpenAI API call + response = await self.openai_client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": query}], + tools=tools, + tool_choice="auto", + ) + + # Get assistant's response + assistant_message = response.choices[0].message + + # Initialize conversation with user query and assistant response + messages = [ + {"role": "user", "content": query}, + assistant_message, + ] + + # Handle tool calls if present + if assistant_message.tool_calls: + # Process each tool call + for tool_call in assistant_message.tool_calls: + # Execute tool call + result = await self.session.call_tool( + tool_call.function.name, + arguments=json.loads(tool_call.function.arguments), + ) + + # Add tool response to conversation + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": result.content[0].text, + } + ) + + # Get final response from OpenAI with tool results + final_response = await self.openai_client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools, + tool_choice="none", # Don't allow more tool calls + ) + + return final_response.choices[0].message.content + + # No tool calls, just return the direct response + return assistant_message.content + + async def cleanup(self): + """Clean up resources.""" + try: + await self.exit_stack.aclose() + except Exception as e: + # Ignore cleanup errors that don't affect functionality + pass + + +async def main(): + """Main entry point for the client.""" + client = MCPOpenAIClient() + try: + await client.connect_to_server("server.py") + + # Example: Ask about company vacation policy + query = "How many employees do we have?" + print(f"\nQuery: {query}") + + response = await client.process_query(query) + print(f"\nResponse: {response}") + finally: + await client.cleanup() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/intro_test/openai_test/server.py b/intro_test/openai_test/server.py new file mode 100644 index 0000000..f647d13 --- /dev/null +++ b/intro_test/openai_test/server.py @@ -0,0 +1,53 @@ +import os +import json +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP( + name="Knowledge Base", + host="0.0.0.0", # only used for SSE transport (localhost) + port=8050, # only used for SSE transport (set this to any port) +) + + +@mcp.tool() +def get_knowledge_base() -> str: + """Retrieve the entire knowledge base as a formatted string. + + Returns: + A formatted string containing all Q&A pairs from the knowledge base. + """ + try: + kb_path = os.path.join(os.path.dirname(__file__), "data", "kb.json") + with open(kb_path, "r") as f: + kb_data = json.load(f) + + # Format the knowledge base as a string + kb_text = "Here is the retrieved knowledge base:\n\n" + + if isinstance(kb_data, list): + for i, item in enumerate(kb_data, 1): + if isinstance(item, dict): + question = item.get("question", "Unknown question") + answer = item.get("answer", "Unknown answer") + else: + question = f"Item {i}" + answer = str(item) + + kb_text += f"Q{i}: {question}\n" + kb_text += f"A{i}: {answer}\n\n" + else: + kb_text += f"Knowledge base content: {json.dumps(kb_data, indent=2)}\n\n" + + return kb_text + except FileNotFoundError: + return "Error: Knowledge base file not found" + except json.JSONDecodeError: + return "Error: Invalid JSON in knowledge base file" + except Exception as e: + return f"Error: {str(e)}" + + +# Run the server +if __name__ == "__main__": + mcp.run(transport="stdio") \ No newline at end of file diff --git a/intro_test/pyproject.toml b/intro_test/pyproject.toml new file mode 100644 index 0000000..e47432f --- /dev/null +++ b/intro_test/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "intro-test" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "mcp[cli]>=1.13.1", + "nest-asyncio", + "openai>=1.0.0", + "pytest>=8.4.2", + "python-dotenv", +] diff --git a/intro_test/server.py b/intro_test/server.py new file mode 100644 index 0000000..85dabd0 --- /dev/null +++ b/intro_test/server.py @@ -0,0 +1,35 @@ +from mcp.server.fastmcp import FastMCP +from dotenv import load_dotenv + +load_dotenv("../.env") + +# Create an MCP server +mcp = FastMCP( + name="Calculator", + host="0.0.0.0", # only used for SSE transport (localhost) + port=8050, # only used for SSE transport (set this to any port) + stateless_http=True, +) + + +# Add a simple calculator tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers together""" + return a + b + + +# Run the server +if __name__ == "__main__": + transport = "sse" + if transport == "stdio": + print("Running server with stdio transport") + mcp.run(transport="stdio") + elif transport == "sse": + print("Running server with SSE transport") + mcp.run(transport="sse") + elif transport == "streamable-http": + print("Running server with Streamable HTTP transport") + mcp.run(transport="streamable-http") + else: + raise ValueError(f"Unknown transport: {transport}") \ No newline at end of file diff --git a/intro_test/uv.lock b/intro_test/uv.lock new file mode 100644 index 0000000..6107a5f --- /dev/null +++ b/intro_test/uv.lock @@ -0,0 +1,624 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "intro-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mcp", extra = ["cli"] }, + { name = "nest-asyncio" }, + { name = "openai" }, + { name = "pytest" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", extras = ["cli"], specifier = ">=1.13.1" }, + { name = "nest-asyncio" }, + { name = "openai", specifier = ">=1.0.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "python-dotenv" }, +] + +[[package]] +name = "jiter" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617, upload-time = "2025-05-18T19:04:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947, upload-time = "2025-05-18T19:04:03.347Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618, upload-time = "2025-05-18T19:04:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829, upload-time = "2025-05-18T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034, upload-time = "2025-05-18T19:04:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529, upload-time = "2025-05-18T19:04:09.566Z" }, + { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671, upload-time = "2025-05-18T19:04:10.98Z" }, + { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864, upload-time = "2025-05-18T19:04:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989, upload-time = "2025-05-18T19:04:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495, upload-time = "2025-05-18T19:04:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289, upload-time = "2025-05-18T19:04:17.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074, upload-time = "2025-05-18T19:04:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225, upload-time = "2025-05-18T19:04:20.583Z" }, + { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235, upload-time = "2025-05-18T19:04:22.363Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278, upload-time = "2025-05-18T19:04:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866, upload-time = "2025-05-18T19:04:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772, upload-time = "2025-05-18T19:04:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534, upload-time = "2025-05-18T19:04:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087, upload-time = "2025-05-18T19:04:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694, upload-time = "2025-05-18T19:04:30.183Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992, upload-time = "2025-05-18T19:04:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723, upload-time = "2025-05-18T19:04:33.467Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215, upload-time = "2025-05-18T19:04:34.827Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762, upload-time = "2025-05-18T19:04:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427, upload-time = "2025-05-18T19:04:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127, upload-time = "2025-05-18T19:04:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527, upload-time = "2025-05-18T19:04:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/3c/82c400c2d50afdac4fbefb5b4031fd327e2ad1f23ccef8eee13c5909aa48/mcp-1.13.1.tar.gz", hash = "sha256:165306a8fd7991dc80334edd2de07798175a56461043b7ae907b279794a834c5", size = 438198, upload-time = "2025-08-22T09:22:16.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/3f/d085c7f49ade6d273b185d61ec9405e672b6433f710ea64a90135a8dd445/mcp-1.13.1-py3-none-any.whl", hash = "sha256:c314e7c8bd477a23ba3ef472ee5a32880316c42d03e06dcfa31a1cc7a73b65df", size = 161494, upload-time = "2025-08-22T09:22:14.705Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "openai" +version = "1.107.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/e0/a62daa7ff769df969cc1b782852cace79615039630b297005356f5fb46fb/openai-1.107.1.tar.gz", hash = "sha256:7c51b6b8adadfcf5cada08a613423575258b180af5ad4bc2954b36ebc0d3ad48", size = 563671, upload-time = "2025-09-10T15:04:40.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/12/32c19999a58eec4a695e8ce334442b6135df949f0bb61b2ceaa4fa60d3a9/openai-1.107.1-py3-none-any.whl", hash = "sha256:168f9885b1b70d13ada0868a0d0adfd538c16a02f7fd9fe063851a2c9a025e72", size = 945177, upload-time = "2025-09-10T15:04:37.782Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typer" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] diff --git a/mcp_llm_client.py b/mcp_llm_client.py new file mode 100644 index 0000000..9885a4e --- /dev/null +++ b/mcp_llm_client.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +""" +MCP AI Client with Flexible Transport and LLM Provider Support + +This client can connect to MCP servers using either SSE or stdio transport +and use various AI models (OpenAI, Claude, Grok) to process queries with access to MCP tools. +""" + +import asyncio +import json +import sys +import os +from contextlib import AsyncExitStack +from typing import Any, Dict, List, Optional +from enum import Enum + +import nest_asyncio +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.client.sse import sse_client + +# Load environment variables +load_dotenv() + +# Apply nest_asyncio to allow nested event loops +nest_asyncio.apply() + +# Import LLM client factory +try: + # Try to import from source first + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + + from mcp_template.llm_client.client_factory import AIClientFactory + LLM_CLIENT_AVAILABLE = True +except ImportError: + try: + # Fallback to build version + from mcp_template.llm_client.client_factory import AIClientFactory + LLM_CLIENT_AVAILABLE = True + except ImportError: + LLM_CLIENT_AVAILABLE = False + + +class TransportType(Enum): + """Supported MCP transport types""" + STDIO = "stdio" + SSE = "sse" + + +class MCPAIClient: + """Client for interacting with AI models using MCP tools with flexible transport and provider support.""" + + def __init__( + self, + model: str = "gpt-4o", + transport: TransportType = TransportType.SSE, + provider: str = "openai", + temperature: float = 0.7, + max_tokens: int = 1000, + top_k: Optional[int] = None, + top_p: Optional[float] = None, + **kwargs + ): + """Initialize the AI MCP client. + + Args: + model: The AI model to use. + transport: The MCP transport type to use (stdio or sse). + provider: The AI provider to use (openai, claude, grok). + temperature: Sampling temperature for AI responses. + max_tokens: Maximum tokens for AI responses. + **kwargs: Additional parameters for the AI client. + """ + self.transport = transport + self.model = model + self.provider = provider + self.temperature = temperature + self.max_tokens = max_tokens + + # Initialize session and client objects + self.session: Optional[ClientSession] = None + self.exit_stack = AsyncExitStack() + + # Initialize AI client using factory + if not LLM_CLIENT_AVAILABLE: + raise ImportError("LLM client not available. Make sure the LLM client modules are properly installed.") + + # Prepare additional parameters for AI client + ai_kwargs = kwargs.copy() + if top_k is not None: + ai_kwargs["top_k"] = top_k + if top_p is not None: + ai_kwargs["top_p"] = top_p + + self.ai_client = AIClientFactory.create_client( + provider=provider, + model_name=model, + temperature=temperature, + max_tokens=max_tokens, + **ai_kwargs + ) + + self.stdio: Optional[Any] = None + self.write: Optional[Any] = None + + # Transport-specific attributes + self.read_stream: Optional[Any] = None + self.write_stream: Optional[Any] = None + + async def connect_stdio(self, server_script_path: str = "run_mcp_server.py", server_args: List[str] = None): + """Connect to an MCP server using stdio transport. + + Args: + server_script_path: Path to the server script. + server_args: Additional arguments for the server script. + """ + if server_args is None: + server_args = ["--transport", "stdio"] + + # Server configuration + server_params = StdioServerParameters( + command="python", + args=[server_script_path] + server_args, + ) + + # Connect to the server + stdio_transport = await self.exit_stack.enter_async_context( + stdio_client(server_params) + ) + self.stdio, self.write = stdio_transport + self.session = await self.exit_stack.enter_async_context( + ClientSession(self.stdio, self.write) + ) + + async def connect_sse(self, server_url: str = "http://localhost:8050/sse"): + """Connect to an MCP server using SSE transport. + + Args: + server_url: The SSE endpoint URL of the server. + """ + # Connect to the server + sse_transport = await self.exit_stack.enter_async_context( + sse_client(server_url) + ) + self.read_stream, self.write_stream = sse_transport + self.session = await self.exit_stack.enter_async_context( + ClientSession(self.read_stream, self.write_stream) + ) + + async def connect_to_server(self, server_url: str = "http://localhost:8050/sse", + server_script_path: str = "run_mcp_server.py"): + """Connect to an MCP server using the configured transport. + + Args: + server_url: The SSE endpoint URL (used for SSE transport). + server_script_path: Path to the server script (used for stdio transport). + """ + if self.transport == TransportType.SSE: + await self.connect_sse(server_url) + elif self.transport == TransportType.STDIO: + await self.connect_stdio(server_script_path) + else: + raise ValueError(f"Unsupported transport type: {self.transport}") + + # Initialize the connection + await self.session.initialize() + + print(f"✅ Connected to MCP server using {self.transport.value.upper()} transport") + + # List available components + await self.list_available_components() + + async def list_available_components(self): + """List all available tools, prompts, and resources.""" + print("\n" + "="*60) + print("📋 AVAILABLE MCP COMPONENTS") + print("="*60) + + # List available tools + tools_result = await self.session.list_tools() + print(f"\n🔧 Tools ({len(tools_result.tools)}):") + for tool in tools_result.tools: + print(f" • {tool.name}: {tool.description}") + + # List available prompts + prompts_result = await self.session.list_prompts() + print(f"\n💬 Prompts ({len(prompts_result.prompts)}):") + for prompt in prompts_result.prompts: + print(f" • {prompt.name}: {prompt.description}") + + # List available resources + resources_result = await self.session.list_resources() + print(f"\n📄 Resources ({len(resources_result.resources)}):") + for resource in resources_result.resources: + print(f" • {resource.uri}: {resource.name}") + if resource.description: + print(f" └─ {resource.description}") + + print("\n" + "="*60) + + + async def get_mcp_tools(self) -> List[Dict[str, Any]]: + """Get available tools from the MCP server in AI provider format. + + Returns: + A list of tools formatted for the specific AI provider. + """ + tools_result = await self.session.list_tools() + + # Convert MCP tools to standard format + standard_tools = [ + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema, + } + for tool in tools_result.tools + ] + + # Use AI client's formatting method + return self.ai_client._format_tools_for_provider(standard_tools) + + async def process_query(self, query: str) -> str: + """Process a query using AI model and available MCP tools. + + Args: + query: The user query. + + Returns: + The response from the AI model. + """ + print(f"\n🤔 Processing query: '{query}'") + + # Get available tools + tools = await self.get_mcp_tools() + + # Initial AI API call + print(f"🧠 Calling {self.provider}/{self.model} with {len(tools)} available tools...") + response = await self.ai_client.chat_completion( + messages=[{"role": "user", "content": query}], + tools=tools, + tool_choice="auto", + ) + + # Get assistant's response + assistant_message = response["choices"][0]["message"] + + # Initialize conversation with user query and assistant response + messages = [ + {"role": "user", "content": query}, + { + "role": assistant_message["role"], + "content": assistant_message.get("content"), + "tool_calls": [ + { + "id": tc["id"], + "type": tc.get("type", "function"), + "function": tc["function"] + } + for tc in assistant_message.get("tool_calls", []) + ] if assistant_message.get("tool_calls") else None, + }, + ] + + # Handle tool calls if present + if "tool_calls" in assistant_message and assistant_message["tool_calls"]: + print(f"🔧 Assistant wants to use {len(assistant_message['tool_calls'])} tool(s)") + + # Process each tool call + for i, tool_call in enumerate(assistant_message["tool_calls"], 1): + tool_name = tool_call["function"]["name"] + tool_args = json.loads(tool_call["function"]["arguments"]) + + print(f" {i}. Calling tool: {tool_name}") + print(f" Arguments: {tool_args}") + + # In proper MCP, arguments should be passed directly + fastmcp_args = tool_args + + # Execute tool call + try: + result = await self.session.call_tool( + tool_name, + arguments=fastmcp_args, + ) + + tool_result = result.content[0].text if result.content else "No result" + print(f" Result: {tool_result}{'...' if len(tool_result) > 100 else ''}") + + # Add tool response to conversation + messages.append( + { + "role": "tool", + "tool_call_id": tool_call["id"], + "content": tool_result, + } + ) + except Exception as e: + error_msg = f"Tool execution failed: {e}" + print(f" ❌ Error: {error_msg}") + messages.append( + { + "role": "tool", + "tool_call_id": tool_call["id"], + "content": error_msg, + } + ) + + # Get final response from AI with tool results + print(f"🧠 Getting final response from {self.provider}/{self.model}...") + final_response = await self.ai_client.chat_completion( + messages=messages, + tools=tools, + tool_choice="none", # Don't allow more tool calls + ) + + final_answer = final_response["choices"][0]["message"]["content"] + print(f"💡 Final answer: {final_answer[:200]}{'...' if len(final_answer) > 200 else ''}") + return final_answer + + # No tool calls, just return the direct response + direct_answer = assistant_message["content"] + print(f"💡 Direct answer: {direct_answer[:200]}{'...' if len(direct_answer) > 200 else ''}") + return direct_answer + + async def interactive_session(self): + """Start an interactive session for querying.""" + print("🚀 Starting interactive MCP-AI session") + print(f"📡 Transport: {self.transport.value.upper()}") + print(f"🤖 Model: {self.provider}/{self.model}") + print(f"🌡️ Temperature: {self.temperature}") + print(f"📏 Max Tokens: {self.max_tokens}") + print("💡 Type 'quit' or 'exit' to end the session") + print("-" * 50) + + while True: + try: + query = input("\n❓ Your query: ").strip() + + if query.lower() in ['quit', 'exit', 'q']: + print("👋 Goodbye!") + break + + if not query: + continue + + # Process the query + response = await self.process_query(query) + print(f"\n🎯 Response: {response}") + + except KeyboardInterrupt: + print("\n👋 Session interrupted. Goodbye!") + break + except Exception as e: + print(f"❌ Error processing query: {e}") + continue + + async def cleanup(self): + """Clean up resources.""" + try: + await self.exit_stack.aclose() + print("🧹 Resources cleaned up successfully") + except Exception as e: + print(f"⚠️ Cleanup warning: {e}") + + +async def main(): + """Main entry point for the client.""" + import argparse + + parser = argparse.ArgumentParser(description="MCP AI Client with Flexible Transport and LLM Provider Support") + parser.add_argument( + "--transport", + choices=["sse", "stdio"], + default="sse", + help="MCP transport type (default: sse)" + ) + parser.add_argument( + "--provider", + choices=["openai", "claude", "grok"], + default="openai", + help="AI provider to use (default: openai)" + ) + parser.add_argument( + "--model", + help="AI model to use (defaults based on provider: openai=gpt-4o, claude=claude-3-opus-20240229, grok=grok-1)" + ) + parser.add_argument( + "--temperature", + type=float, + default=0.7, + help="Sampling temperature (default: 0.7)" + ) + parser.add_argument( + "--max-tokens", + type=int, + default=1000, + help="Maximum tokens for response (default: 1000)" + ) + parser.add_argument( + "--top-k", + type=int, + help="Top-k sampling parameter (provider-specific)" + ) + parser.add_argument( + "--top-p", + type=float, + help="Top-p sampling parameter (provider-specific)" + ) + parser.add_argument( + "--server-url", + default="http://localhost:8050/sse", + help="Server URL for SSE transport (default: http://localhost:8050/sse)" + ) + parser.add_argument( + "--server-script", + default="run_mcp_server.py", + help="Server script path for stdio transport (default: run_mcp_server.py)" + ) + parser.add_argument( + "--query", + help="Single query to process (if not provided, starts interactive mode)" + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose output" + ) + + args = parser.parse_args() + + # Validate API keys based on provider + provider = args.provider + api_key_env_vars = { + "openai": "OPENAI_API_KEY", + "claude": "ANTHROPIC_API_KEY", + "grok": "GROK_API_KEY" + } + + api_key_env = api_key_env_vars.get(provider) + if not api_key_env: + print(f"❌ Error: Unknown provider: {provider}") + return + + api_key = os.getenv(api_key_env) + if not api_key: + print(f"❌ Error: {api_key_env} environment variable not set") + print(f" Please set your {provider.upper()} API key:") + print(f" export {api_key_env}='your-api-key-here'") + print(f" Or create a .env file with: {api_key_env}=your-api-key-here") + return + + # Basic API key validation + if provider == "openai" and (not api_key.startswith("sk-") or len(api_key) < 20): + print("❌ Error: OPENAI_API_KEY appears to be invalid") + print(" API key should start with 'sk-' and be at least 20 characters long") + return + + # Set default model if not provided + model = args.model + if not model: + default_models = { + "openai": "gpt-4o", + "claude": "claude-3-opus-20240229", + "grok": "grok-1" + } + model = default_models.get(provider, "gpt-4o") + + # Create client + transport = TransportType(args.transport) + client = MCPAIClient( + model=model, + transport=transport, + provider=provider, + temperature=args.temperature, + max_tokens=args.max_tokens, + top_k=args.top_k, + top_p=args.top_p + ) + + try: + # Connect to server + if transport == TransportType.SSE: + await client.connect_to_server(server_url=args.server_url) + else: + await client.connect_to_server(server_script_path=args.server_script) + + # Handle single query or interactive mode + if args.query: + response = await client.process_query(args.query) + print(f"\n🎯 Response: {response}") + else: + await client.interactive_session() + + except Exception as e: + print(f"❌ Error: {e}") + if "ConnectionRefusedError" in str(e): + if transport == TransportType.SSE: + print("💡 Make sure the MCP server is running with SSE transport:") + print(f" python {args.server_script} --transport sse") + else: + print("💡 Make sure the server script path is correct") + finally: + await client.cleanup() + + +# Backward compatibility alias +MCPOpenAIClient = MCPAIClient + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f2c9bfa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "mcp-template" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "mcp[cli]>=1.14.0", + "nest-asyncio>=1.6.0", + "openai>=1.107.1", + "psutil", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eea2f67 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,20 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +pythonpath = src +addopts = + -v + --tb=short + --strict-markers + --asyncio-mode=auto + --disable-warnings +markers = + integration: marks tests as integration tests (deselect with '-m "not integration"') + unit: marks tests as unit tests + slow: marks tests as slow (deselect with '-m "not slow"') + requires_api_key: marks tests that require API keys +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0b81610 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +anthropic +openai +mcp[cli] +pytest +pytest-asyncio +uvicorn +fastapi +httpx +pydantic +nest-asyncio +python-dotenv +psutil \ No newline at end of file diff --git a/run_mcp_server.py b/run_mcp_server.py new file mode 100644 index 0000000..b9d4b63 --- /dev/null +++ b/run_mcp_server.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +MCP Modular Server Runner +Run the modular MCP server with dynamic transport selection +""" +import argparse +import sys +import os +import logging + +# Default logging configuration (can be overridden by --verbose) +logging.getLogger("mcp").setLevel(logging.WARNING) +logging.getLogger("uvicorn").setLevel(logging.WARNING) +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + +# Add the src directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from mcp_template.server.modular_server import create_default_server + +# Global server object for MCP dev command compatibility +mcp_server = None +mcp = None # Global MCP object for `mcp dev` command compatibility + + +def get_dev_mcp(): + """Get the MCP object for dev mode""" + global mcp + if mcp is None: + try: + # Create a basic server instance for dev mode + dev_server = create_default_server("stdio", 8050) + mcp = dev_server.mcp # Extract the FastMCP instance + except Exception as e: + print(f"Warning: Could not create MCP server for dev mode: {e}") + mcp = None + return mcp + + +# Initialize MCP object for dev command +mcp = get_dev_mcp() + + +def get_mcp_server(transport: str = "stdio", port: int = 8050): + """Get or create the MCP server instance""" + global mcp_server + + if mcp_server is None: + mcp_server = create_default_server(transport, port) + + return mcp_server + + +def run_server(transport: str = "stdio", port: int = 8050) -> None: + """Run the modular MCP server with the specified transport method and port""" + supported_transports = ["stdio", "sse", "streamable-http"] + + if transport not in supported_transports: + raise ValueError(f"Unknown transport: {transport}. Supported: {supported_transports}") + + print(f"Starting MCP Modular Server with {transport.upper()} transport on port {port}") + + # Create and run the server + server = get_mcp_server(transport, port) + + if transport == "stdio": + print("Server ready for stdio communication") + elif transport == "sse": + print(f"Server ready for SSE communication on http://0.0.0.0:{port}/sse") + elif transport == "streamable-http": + print(f"Server ready for Streamable HTTP communication on port {port}") + + # Run the server + server.run(transport) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Run MCP Modular Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python run_mcp_server.py # Run with stdio on port 8050 (default) + python run_mcp_server.py --transport stdio # Run with stdio explicitly + python run_mcp_server.py --transport sse # Run with SSE transport + python run_mcp_server.py --transport sse --port 8080 # Run SSE on custom port + python run_mcp_server.py --transport streamable-http --port 9000 # Run Streamable HTTP on port 9000 + python run_mcp_server.py --dev # Run in development mode (SSE on port 3000) + python run_mcp_server.py --dev --verbose # Dev mode with detailed logging + +MCP Dev Command: + mcp dev dev_run.py # Use MCP dev command with dedicated dev file + +The server will automatically discover and register: +- Tools from the tools directory +- Prompts from the prompts directory +- Resources from the resources directory + """ + ) + parser.add_argument( + "--transport", + choices=["stdio", "sse", "streamable-http"], + default="stdio", + help="Transport method to use (default: stdio)" + ) + parser.add_argument( + "--port", + type=int, + default=8050, + help="Port to run the server on (default: 8050, only used for HTTP transports)" + ) + parser.add_argument( + "--dev", + action="store_true", + help="Run in development mode with SSE transport on port 3000" + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose logging (shows all MCP and server messages)" + ) + + args = parser.parse_args() + + # Configure logging based on verbose flag + if args.verbose: + logging.getLogger("mcp").setLevel(logging.INFO) + logging.getLogger("uvicorn").setLevel(logging.INFO) + logging.getLogger("uvicorn.access").setLevel(logging.INFO) + print("Verbose logging enabled") + else: + # Keep the reduced logging for cleaner output + logging.getLogger("mcp").setLevel(logging.WARNING) + logging.getLogger("uvicorn").setLevel(logging.WARNING) + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + + if args.dev: + # In dev mode, run with SSE transport on a standard dev port + # This is what MCP dev command expects + print("Starting MCP server in development mode...") + server = get_mcp_server("sse", 3000) # Standard dev port + print(f"🌐 Server ready for SSE communication on http://0.0.0.0:3000/sse") + server.run("sse") + + run_server(args.transport, args.port) diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..5229621 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Test Runner for MCP Template + +This script provides an easy way to run different types of tests +with appropriate configurations and environment setup. +""" + +import os +import sys +import argparse +import subprocess +from pathlib import Path + + +def run_command(cmd, cwd=None, env=None): + """Run a command and return the result.""" + try: + result = subprocess.run( + cmd, + shell=True, + cwd=cwd or os.getcwd(), + env=env or os.environ.copy(), + capture_output=True, + text=True + ) + return result.returncode == 0, result.stdout, result.stderr + except Exception as e: + return False, "", str(e) + + +def setup_environment(): + """Set up environment variables for testing.""" + env = os.environ.copy() + + # Load .env file if it exists + env_file = Path(".env") + if env_file.exists(): + try: + from dotenv import load_dotenv + load_dotenv() + env.update(os.environ.copy()) + except ImportError: + print("Warning: python-dotenv not installed, .env file not loaded") + + return env + + +def run_unit_tests(args): + """Run unit tests.""" + print("🧪 Running Unit Tests...") + + cmd = ["python", "-m", "pytest", "tests/unit/", "-v"] + if args.coverage: + cmd.extend(["--cov=src", "--cov-report=html", "--cov-report=term"]) + + success, stdout, stderr = run_command(" ".join(cmd)) + print(stdout) + if stderr: + print(stderr) + + return success + + +def run_integration_tests(args): + """Run integration tests.""" + print("🔗 Running Integration Tests...") + + # Check for API keys + has_openai = bool(os.getenv("OPENAI_API_KEY")) + has_anthropic = bool(os.getenv("ANTHROPIC_API_KEY")) + has_grok = bool(os.getenv("GROK_API_KEY")) + + if not any([has_openai, has_anthropic, has_grok]): + print("⚠️ Warning: No API keys found. Integration tests will be skipped.") + print(" Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or GROK_API_KEY environment variables.") + + cmd = ["python", "-m", "pytest", "tests/integration/", "-v", "-s"] + + if args.coverage: + cmd.extend(["--cov=src", "--cov-report=html", "--cov-report=term"]) + + if args.skip_slow: + cmd.append("-m") + cmd.append("not slow") + + success, stdout, stderr = run_command(" ".join(cmd)) + print(stdout) + if stderr: + print(stderr) + + return success + + +def run_all_tests(args): + """Run all tests.""" + print("🚀 Running All Tests...") + + success1 = run_unit_tests(args) + success2 = run_integration_tests(args) + + return success1 and success2 + + +def run_specific_test(args): + """Run a specific test file or test function.""" + print(f"🎯 Running Specific Test: {args.test_path}") + + cmd = ["python", "-m", "pytest", args.test_path, "-v", "-s"] + + if args.coverage: + cmd.extend(["--cov=src", "--cov-report=html", "--cov-report=term"]) + + success, stdout, stderr = run_command(" ".join(cmd)) + print(stdout) + if stderr: + print(stderr) + + return success + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Test Runner for MCP Template", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run all tests + python run_tests.py all + + # Run unit tests only + python run_tests.py unit + + # Run integration tests only + python run_tests.py integration + + # Run specific test file + python run_tests.py specific tests/integration/test_mcp_integration.py + + # Run with coverage + python run_tests.py all --coverage + + # Run integration tests but skip slow ones + python run_tests.py integration --skip-slow + """ + ) + + parser.add_argument( + "command", + choices=["unit", "integration", "all", "specific"], + help="Type of tests to run" + ) + + parser.add_argument( + "test_path", + nargs="?", + help="Path to specific test file or test function (for 'specific' command)" + ) + + parser.add_argument( + "--coverage", + action="store_true", + help="Generate coverage report" + ) + + parser.add_argument( + "--skip-slow", + action="store_true", + help="Skip slow tests (integration tests only)" + ) + + args = parser.parse_args() + + # Set up environment + env = setup_environment() + + # Change to the correct working directory + os.chdir(Path(__file__).parent) + + # Run the appropriate test command + if args.command == "unit": + success = run_unit_tests(args) + elif args.command == "integration": + success = run_integration_tests(args) + elif args.command == "all": + success = run_all_tests(args) + elif args.command == "specific": + if not args.test_path: + print("❌ Error: test_path is required for 'specific' command") + sys.exit(1) + success = run_specific_test(args) + else: + print("❌ Unknown command") + success = False + + # Exit with appropriate code + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_template.egg-info/PKG-INFO b/src/mcp_template.egg-info/PKG-INFO new file mode 100644 index 0000000..c93ef7d --- /dev/null +++ b/src/mcp_template.egg-info/PKG-INFO @@ -0,0 +1,602 @@ +Metadata-Version: 2.4 +Name: mcp-template +Version: 0.1.0 +Summary: Add your description here +Requires-Python: >=3.13 +Description-Content-Type: text/markdown +Requires-Dist: mcp[cli]>=1.14.0 +Requires-Dist: nest-asyncio>=1.6.0 +Requires-Dist: openai>=1.107.1 +Requires-Dist: psutil + +# MCP Template Server & LLM Client + +A comprehensive Python implementation of the **Model Context Protocol (MCP)** with multi-provider LLM support, featuring modular tools, prompts, and resources. This project provides a production-ready MCP server and flexible client that supports OpenAI, Claude, and Grok models. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Introduction](#introduction) +- [Architecture & Code Structure](#architecture--code-structure) +- [Tools](#tools) +- [Prompts](#prompts) +- [Resources](#resources) +- [Test Cases](#test-cases) +- [Configuration](#configuration) +- [Running the System](#running-the-system) +- [Development](#development) +- [API Reference](#api-reference) +- [Contributing](#contributing) + +## Quick Start + +### Basic Usage (5 minutes) + +```bash +# 1. Install dependencies +pip install -r requirements.txt + +# 2. Set your API key (choose your provider) +export OPENAI_API_KEY="your-openai-key" +# OR +export ANTHROPIC_API_KEY="your-claude-key" +# OR +export GROK_API_KEY="your-grok-key" + +# 3. Start the MCP server +python run_mcp_server.py --transport sse + +# 4. Run the client (in another terminal) +python mcp_llm_client.py --provider openai --query "Calculate 25 squared" +``` + +### Output +``` +Connected to MCP server using SSE transport +Processing query: 'Calculate 25 squared' +Calling openai/gpt-4o with 24 available tools... +Assistant wants to use 1 tool(s) + 1. Calling tool: power + Arguments: {'kwargs': '{"base": 25, "exponent": 2}'} + Result: 625 +Getting final response from openai/gpt-4o... +Final answer: 25 squared is 625. +``` + +## Introduction + +### What is MCP? + +**MCP (Model Context Protocol)** is an open standard for connecting AI applications to external systems and data sources. It enables AI models to: + +- **Execute tools** (calculations, API calls, system commands) +- **Access resources** (files, databases, configurations) +- **Use prompts** (reusable conversation templates) + +### Getting Started with MCP + +For a basic introduction to MCP concepts, see the [`intro_test/`](./intro_test/) directory, which contains: + +- **[Complete MCP Guide](./intro_test/INTRO_README.md)** - Comprehensive introduction to MCP concepts +- **Basic Server & Client Examples** - Simple implementations using FastMCP +- **Transport Protocol Examples** - SSE and STDIO transport implementations +- **OpenAI Integration** - Basic MCP-OpenAI client example + +> **New to MCP?** Start with [`intro_test/INTRO_README.md`](./intro_test/INTRO_README.md) for a complete beginner's guide. + +## Architecture & Code Structure + +``` +mcp_template/ +├── src/mcp_template/ +│ ├── core/ # Core MCP types and interfaces +│ ├── llm_client/ # Multi-provider LLM client factory +│ │ ├── base_client.py # Abstract base client +│ │ ├── openai_client.py # OpenAI implementation +│ │ ├── claude_client.py # Claude implementation +│ │ ├── grok_client.py # Grok implementation +│ │ └── client_factory.py # Factory for creating clients +│ ├── server/ +│ │ ├── modular_server.py # Main MCP server +│ │ ├── tools/ # Tool registration system +│ │ ├── prompts/ # Prompt management +│ │ └── resources/ # Resource management +│ └── tools/ # Tool implementations +│ ├── math_tools.py # Mathematical operations +│ ├── text_tools.py # Text processing +│ ├── system_tools.py # System utilities +│ └── web_tools.py # Web utilities +├── intro_test/ # Basic MCP examples +├── mcp_llm_client.py # Multi-provider MCP client +├── run_mcp_server.py # Server launcher +└── config.py # Configuration management +``` + +### Key Components + +- **`modular_server.py`** - Main MCP server with auto-discovery +- **`mcp_llm_client.py`** - Client supporting multiple AI providers +- **`client_factory.py`** - Factory pattern for LLM client creation +- **Tool Registry** - Automatic tool discovery and registration +- **Transport Layer** - SSE and STDIO transport implementations + +## Tools + +Tools are executable functions that AI models can invoke to perform actions. The system includes **24 built-in tools** across multiple categories. + +### Available Tool Categories + +#### Mathematical Tools (`math_tools.py`) +```python +# Available functions: add, subtract, multiply, divide, power, square_root, calculate_bmi +result = await add(a=10, b=5) # Returns: 15 +result = await power(base=5, exponent=3) # Returns: 125 +result = await square_root(number=144) # Returns: 12.0 +``` + +#### Text Processing Tools (`text_tools.py`) +```python +# Available functions: count_words, search_text, replace_text, to_uppercase, to_lowercase, text_length +word_count = await count_words(text="Hello world") # Returns: 2 +uppercase = await to_uppercase(text="hello") # Returns: "HELLO" +``` + +#### System Tools (`system_tools.py`) +```python +# Available functions: get_system_info, get_current_time, list_directory, get_file_info, get_environment_variable +sys_info = await get_system_info() # Returns system information +current_time = await get_current_time() # Returns: "2024-01-15 14:30:25" +``` + +#### Web Tools (`web_tools.py`) +```python +# Available functions: url_encode, url_decode, parse_url, validate_email, extract_domain +encoded = await url_encode(text="hello world") # Returns: "hello%20world" +is_valid = await validate_email(email="user@example.com") # Returns: True +``` + +### Creating Custom Tools + +#### Method 1: Using MCPTool Class +```python +from mcp_template.core.types import MCPTool + +async def custom_calculator(x: float, operation: str) -> float: + """Perform custom calculation""" + if operation == "double": + return x * 2 + elif operation == "half": + return x / 2 + return x + +tool = MCPTool( + name="custom_calculator", + description="Perform custom calculations", + input_schema={ + "type": "object", + "properties": { + "x": {"type": "number", "description": "Input number"}, + "operation": { + "type": "string", + "enum": ["double", "half"], + "description": "Operation to perform" + } + }, + "required": ["x", "operation"] + }, + handler=custom_calculator +) +``` + +#### Method 2: Using Tool Classes +```python +from mcp_template.tools.tool_registry import ServerToolRegistry + +class CustomTools: + @staticmethod + def get_tools(): + return [ + CustomTools._create_custom_tool(), + ] + + @staticmethod + def _create_custom_tool(): + async def my_tool(param1: str, param2: int = 0) -> str: + """Description of my tool""" + return f"Processed {param1} with {param2}" + + return MCPTool( + name="my_custom_tool", + description="Custom tool description", + input_schema={ + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter", "default": 0} + }, + "required": ["param1"] + }, + handler=my_tool + ) +``` + +#### Method 3: FastMCP Integration +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("MyServer") + +@mcp.tool() +async def my_fastmcp_tool(input: str) -> str: + """FastMCP tool with automatic schema generation""" + return f"Processed: {input}" + +# Tool is automatically registered with proper schema +``` + +### Tool Registration + +Tools are automatically discovered and registered: + +```python +# Automatic registration (recommended) +from mcp_template.server.modular_server import create_default_server + +server = create_default_server() +await server.initialize() # Automatically discovers and registers all tools + +# Manual registration +from mcp_template.server.tools.tool_registry import ServerToolRegistry + +registry = ServerToolRegistry() +registry.register_tools(your_custom_tools) +``` + +## Prompts + +Prompts are reusable conversation templates that help AI models perform specific tasks. + +### Available Prompts + +#### Greeting Prompt +```python +from mcp_template.server.prompts.greeting_prompt import GreetingPrompt + +prompt = GreetingPrompt() +result = await prompt.generate( + name="Alice", + style="friendly", + tone="warm", + element="compliment" +) +# Returns: Template with substituted variables +``` + +### Creating Custom Prompts + +```python +from mcp_template.server.prompts.base_prompt import BaseServerPrompt + +class CodeReviewPrompt(BaseServerPrompt): + def __init__(self): + super().__init__( + name="code_review", + description="Generate code review feedback", + template=""" + Please review the following {language} code for: + - Code quality and best practices + - Potential bugs or security issues + - Performance optimizations + - Readability improvements + + Code to review: + {code} + + Focus on: {focus_areas} + """, + arguments={ + "language": { + "type": "string", + "enum": ["python", "javascript", "java", "cpp"], + "description": "Programming language" + }, + "focus_areas": { + "type": "string", + "description": "Areas to focus the review on" + } + } + ) + + async def generate(self, code: str, language: str, focus_areas: str = "all") -> str: + return self._substitute_template( + code=code, + language=language, + focus_areas=focus_areas + ) +``` + +## Resources + +Resources represent data sources that AI models can access and read. + +### Available Resources + +#### Configuration Resource +```python +# URI: config://settings +# Returns: Server configuration in JSON format +{ + "server_name": "MCP Template Server", + "version": "1.0.0", + "features": ["tools", "prompts", "resources"], + "transport": {"supported": ["stdio", "sse"], "default": "stdio"}, + "tools_count": 24, + "prompts_count": 1, + "resources_count": 2 +} +``` + +#### Dynamic Greeting Resource +```python +# URI: dynamic://greeting +# Returns: Personalized greeting with current timestamp +``` + +### Creating Custom Resources + +```python +from mcp_template.server.resources.base_resource import BaseServerResource + +class DatabaseResource(BaseServerResource): + def __init__(self, db_path: str): + super().__init__( + uri=f"db://{db_path}", + name="Database Connection", + description=f"Access to database: {db_path}", + mime_type="application/json" + ) + self.db_path = db_path + + async def read(self, query: str = None, **kwargs) -> str: + """Execute database query and return results""" + # Implement database logic here + import json + + if query: + # Execute specific query + result = {"query": query, "result": "mock_data"} + else: + # Return database info + result = {"database": self.db_path, "status": "connected"} + + return json.dumps(result, indent=2) +``` + +## Test Cases + +Test cases should be written for each tool category to ensure proper functionality: + +### Mathematical Tools Tests +Should test basic arithmetic, power functions, square root calculations, and BMI calculations. + +### Text Tools Tests +Should test word counting, case conversion, text search, and text replacement functionality. + +### System Tools Tests +Should test system information retrieval, current time, and environment variable access. + +### Web Tools Tests +Should test URL encoding/decoding, email validation, and domain extraction. + +## Configuration + +### Environment Variables + +Create a `.env` file in the project root: + +```bash +# OpenAI (default provider) +OPENAI_API_KEY=sk-your-openai-key-here + +# Claude (alternative provider) +ANTHROPIC_API_KEY=sk-ant-your-claude-key-here + +# Grok (alternative provider) +GROK_API_KEY=xai-your-grok-key-here +``` + +### Server Configuration + +The server automatically discovers and registers: +- **Tools**: All classes in `src/mcp_template/tools/` +- **Prompts**: All classes in `src/mcp_template/server/prompts/` +- **Resources**: All classes in `src/mcp_template/server/resources/` + +## Running the System + +### Transport Methods + +#### 1. Server-Sent Events (SSE) - Recommended + +**Start Server:** +```bash +python run_mcp_server.py --transport sse --port 8050 +``` + +**Run Client:** +```bash +# Interactive mode +python mcp_llm_client.py --provider openai --model gpt-4o + +# Single query +python mcp_llm_client.py --provider openai --query "Calculate 25 + 30" + +# With custom parameters +python mcp_llm_client.py --provider openai --model gpt-4o --temperature 0.7 --max-tokens 1000 +``` + +#### 2. Standard Input/Output (STDIO) + +**Note:** STDIO transport doesn't require starting a separate server. The client automatically launches the server process. + +```bash +# The client will automatically start the server +python mcp_llm_client.py --transport stdio --provider openai --query "Calculate 10 * 5" +``` + +### Advanced Usage + +#### Multiple Providers +```bash +# OpenAI (default) +python mcp_llm_client.py --provider openai --model gpt-4o + +# Claude +python mcp_llm_client.py --provider claude --model claude-3-opus-20240229 + +# Grok +python mcp_llm_client.py --provider grok --model grok-1 +``` + +#### Custom Parameters +```bash +python mcp_llm_client.py \ + --provider openai \ + --model gpt-4o \ + --temperature 0.01 \ + --max-tokens 500 \ + --top-p 0.9 \ + --query "Explain quantum computing" +``` + +#### Interactive Mode +```bash +python mcp_llm_client.py --provider openai --temperature 0.7 + +# Now you can have natural conversations: +# Your query: Calculate 15 + 27 +# Response: 15 + 27 equals 42. +# +# Your query: What's the square root of 144? +# Response: The square root of 144 is 12. +# +# Your query: Count words in "Hello beautiful world" +# Response: The text contains 3 words. +``` + +### Docker Usage (Future) + +```bash +# Build the image +docker build -t mcp-template . + +# Run with environment variables +docker run -e OPENAI_API_KEY=your-key -p 8050:8050 mcp-template +``` + +## Development + +### Adding New Tools + +1. **Create tool file** in `src/mcp_template/tools/` +2. **Implement tool class** with `get_tools()` method +3. **Return MCPTool objects** with proper schemas +4. **Tools are auto-discovered** - no registration needed + +### Adding New Prompts + +1. **Create prompt class** in `src/mcp_template/server/prompts/` +2. **Extend BaseServerPrompt** +3. **Implement template and arguments** +4. **Prompts are auto-discovered** + +### Adding New Resources + +1. **Create resource class** in `src/mcp_template/server/resources/` +2. **Extend BaseServerResource** +3. **Implement read() method** +4. **Resources are auto-discovered** + +### Testing + +```bash +# Run all tests +python -m pytest tests/ + +# Run specific test file +python -m pytest tests/unit/test_tools.py + +# Run with coverage +python -m pytest --cov=src tests/ +``` + +## API Reference + +### MCPAIClient + +```python +class MCPAIClient: + def __init__( + self, + model: str = "gpt-4o", + transport: TransportType = TransportType.SSE, + provider: str = "openai", + temperature: float = 0.7, + max_tokens: int = 1000, + **kwargs + ) + + async def process_query(self, query: str) -> str + async def get_mcp_tools(self) -> List[Dict[str, Any]] + async def interactive_session(self) +``` + +### AIClientFactory + +```python +class AIClientFactory: + @staticmethod + def create_client(provider: str, model_name: str, **kwargs) -> BaseAIClient + + @staticmethod + def create_openai_client(model_name: str = "gpt-4o", **kwargs) -> OpenAIClient + + @staticmethod + def create_claude_client(model_name: str = "claude-3-opus-20240229", **kwargs) -> ClaudeClient + + @staticmethod + def create_grok_client(model_name: str = "grok-1", **kwargs) -> GrokClient +``` + +## Contributing + +1. **Fork the repository** +2. **Create feature branch**: `git checkout -b feature/your-feature` +3. **Add tests** for new functionality +4. **Ensure tests pass**: `python -m pytest` +5. **Submit pull request** + +### Code Standards + +- Use type hints for all function parameters and return values +- Follow PEP 8 style guidelines +- Add docstrings to all classes and methods +- Include unit tests for new features +- Update documentation for API changes + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +- **Documentation**: See [`intro_test/INTRO_README.md`](./intro_test/INTRO_README.md) for MCP basics +- **Issues**: Report bugs on GitHub Issues +- **Discussions**: Join discussions on GitHub Discussions +- **Email**: Contact maintainers for support + +--- + +**Built with using the Model Context Protocol (MCP)** + +*Transforming AI applications with standardized tool integration* diff --git a/src/mcp_template.egg-info/SOURCES.txt b/src/mcp_template.egg-info/SOURCES.txt new file mode 100644 index 0000000..5f20167 --- /dev/null +++ b/src/mcp_template.egg-info/SOURCES.txt @@ -0,0 +1,53 @@ +README.md +pyproject.toml +src/__init__.py +src/mcp_template/__init__.py +src/mcp_template/mcp_client.py +src/mcp_template.egg-info/PKG-INFO +src/mcp_template.egg-info/SOURCES.txt +src/mcp_template.egg-info/dependency_links.txt +src/mcp_template.egg-info/requires.txt +src/mcp_template.egg-info/top_level.txt +src/mcp_template/config/__init__.py +src/mcp_template/config/client_config.py +src/mcp_template/config/config_manager.py +src/mcp_template/config/server_config.py +src/mcp_template/config/transport_config.py +src/mcp_template/core/__init__.py +src/mcp_template/core/interfaces.py +src/mcp_template/core/types.py +src/mcp_template/examples/__init__.py +src/mcp_template/examples/server_examples.py +src/mcp_template/llm_client/__init__.py +src/mcp_template/llm_client/base_client.py +src/mcp_template/llm_client/claude_client.py +src/mcp_template/llm_client/client_factory.py +src/mcp_template/llm_client/grok_client.py +src/mcp_template/llm_client/openai_client.py +src/mcp_template/server/__init__.py +src/mcp_template/server/modular_server.py +src/mcp_template/server/server_factory.py +src/mcp_template/server/prompts/__init__.py +src/mcp_template/server/prompts/base_prompt.py +src/mcp_template/server/prompts/greeting_prompt.py +src/mcp_template/server/prompts/prompt_registry.py +src/mcp_template/server/resources/__init__.py +src/mcp_template/server/resources/base_resource.py +src/mcp_template/server/resources/config_resource.py +src/mcp_template/server/resources/dynamic_resource.py +src/mcp_template/server/resources/resource_registry.py +src/mcp_template/server/tools/__init__.py +src/mcp_template/server/tools/base_tool.py +src/mcp_template/server/tools/calculator_tool.py +src/mcp_template/server/tools/greeting_tool.py +src/mcp_template/server/tools/tool_registry.py +src/mcp_template/tools/__init__.py +src/mcp_template/tools/math_tools.py +src/mcp_template/tools/system_tools.py +src/mcp_template/tools/text_tools.py +src/mcp_template/tools/tool_registry.py +src/mcp_template/tools/web_tools.py +src/mcp_template/transport/__init__.py +src/mcp_template/transport/sse_transport.py +src/mcp_template/transport/stdio_transport.py +src/mcp_template/transport/transport_manager.py \ No newline at end of file diff --git a/src/mcp_template.egg-info/dependency_links.txt b/src/mcp_template.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/mcp_template.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/mcp_template.egg-info/requires.txt b/src/mcp_template.egg-info/requires.txt new file mode 100644 index 0000000..cfcb767 --- /dev/null +++ b/src/mcp_template.egg-info/requires.txt @@ -0,0 +1,4 @@ +mcp[cli]>=1.14.0 +nest-asyncio>=1.6.0 +openai>=1.107.1 +psutil diff --git a/src/mcp_template.egg-info/top_level.txt b/src/mcp_template.egg-info/top_level.txt new file mode 100644 index 0000000..785cbb7 --- /dev/null +++ b/src/mcp_template.egg-info/top_level.txt @@ -0,0 +1,2 @@ +__init__ +mcp_template diff --git a/src/mcp_template/__init__.py b/src/mcp_template/__init__.py new file mode 100644 index 0000000..e2d634e --- /dev/null +++ b/src/mcp_template/__init__.py @@ -0,0 +1,5 @@ +""" +MCP Template Source Package +""" + +__version__ = "0.1.0" diff --git a/src/mcp_template/config/__init__.py b/src/mcp_template/config/__init__.py new file mode 100644 index 0000000..bddd5cc --- /dev/null +++ b/src/mcp_template/config/__init__.py @@ -0,0 +1,7 @@ +# Configuration management +from .config_manager import ConfigManager +from .server_config import ServerConfig +from .client_config import ClientConfig +from .transport_config import TransportConfig + +__all__ = ['ConfigManager', 'ServerConfig', 'ClientConfig', 'TransportConfig'] diff --git a/src/mcp_template/config/client_config.py b/src/mcp_template/config/client_config.py new file mode 100644 index 0000000..f87a259 --- /dev/null +++ b/src/mcp_template/config/client_config.py @@ -0,0 +1,70 @@ +""" +Client Configuration +""" +from typing import Dict, Any, Optional +from dataclasses import dataclass + + +@dataclass +class ClientConfig: + """Configuration class for MCP clients""" + + provider: str = "openai" + model: str = "gpt-4o" + api_key: Optional[str] = None + + # Model parameters + temperature: float = 0.7 + max_tokens: int = 1000 + top_p: float = 1.0 + + # Connection settings + timeout: int = 30 + max_retries: int = 3 + retry_delay: float = 1.0 + + # MCP-specific settings + enable_tool_calling: bool = True + enable_resource_access: bool = True + enable_prompts: bool = True + + # Transport settings + transport_host: str = "localhost" + transport_port: int = 8050 + transport_endpoint: str = "/sse" + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'ClientConfig': + """Create ClientConfig from dictionary""" + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return self.__dict__.copy() + + def get_provider_config(self) -> Dict[str, Any]: + """Get provider-specific configuration""" + return { + "model_name": self.model, + "api_key": self.api_key, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "top_p": self.top_p, + "timeout": self.timeout, + "max_retries": self.max_retries, + "retry_delay": self.retry_delay, + } + + def validate(self) -> bool: + """Validate configuration""" + if not self.provider: + return False + if not self.model: + return False + if not self.api_key: + return False + if self.temperature < 0 or self.temperature > 2: + return False + if self.max_tokens < 1: + return False + return True diff --git a/src/mcp_template/config/config_manager.py b/src/mcp_template/config/config_manager.py new file mode 100644 index 0000000..dfbc121 --- /dev/null +++ b/src/mcp_template/config/config_manager.py @@ -0,0 +1,133 @@ +""" +Centralized Configuration Manager +""" +import os +import json +from typing import Dict, Any, Optional +from pathlib import Path + +try: + from dotenv import load_dotenv + DOTENV_AVAILABLE = True +except ImportError: + DOTENV_AVAILABLE = False + + +class ConfigManager: + """Centralized configuration manager for MCP components""" + + def __init__(self, config_file: Optional[str] = None, env_file: Optional[str] = None): + self._config_file = config_file or "config.json" + self._env_file = env_file or ".env" + self._config: Dict[str, Any] = {} + self._loaded = False + + async def load_config(self) -> Dict[str, Any]: + """Load configuration from all sources""" + if self._loaded: + return self._config + + # Load environment variables + await self._load_env_vars() + + # Load JSON configuration file + await self._load_json_config() + + # Merge configurations + self._config = await self._merge_configs() + + self._loaded = True + return self._config + + async def _load_env_vars(self) -> None: + """Load environment variables""" + if DOTENV_AVAILABLE and Path(self._env_file).exists(): + load_dotenv(self._env_file) + + async def _load_json_config(self) -> Dict[str, Any]: + """Load JSON configuration file""" + config_path = Path(self._config_file) + + if config_path.exists(): + with open(config_path, 'r') as f: + return json.load(f) + + return {} + + async def _merge_configs(self) -> Dict[str, Any]: + """Merge all configuration sources""" + merged = {} + + # Start with JSON config as base + json_config = await self._load_json_config() + merged.update(json_config) + + # Override with environment variables + env_config = await self._get_env_config() + self._deep_update(merged, env_config) + + return merged + + async def _get_env_config(self) -> Dict[str, Any]: + """Get configuration from environment variables""" + env_config = {} + + # Server configuration + if os.getenv("MCP_SERVER_NAME"): + env_config.setdefault("server", {})["name"] = os.getenv("MCP_SERVER_NAME") + if os.getenv("MCP_SERVER_HOST"): + env_config.setdefault("server", {})["host"] = os.getenv("MCP_SERVER_HOST") + if os.getenv("MCP_SERVER_PORT"): + env_config.setdefault("server", {})["port"] = int(os.getenv("MCP_SERVER_PORT")) + if os.getenv("MCP_TRANSPORT"): + env_config.setdefault("server", {})["transport"] = os.getenv("MCP_TRANSPORT") + + # Client configuration + if os.getenv("MCP_AI_PROVIDER"): + env_config.setdefault("client", {})["provider"] = os.getenv("MCP_AI_PROVIDER") + if os.getenv("MCP_AI_MODEL"): + env_config.setdefault("client", {})["model"] = os.getenv("MCP_AI_MODEL") + + # API keys + for provider in ["OPENAI", "CLAUDE", "GROK"]: + api_key = os.getenv(f"{provider}_API_KEY") + if api_key: + env_config.setdefault("api_keys", {})[provider.lower()] = api_key + + return env_config + + def _deep_update(self, base_dict: Dict[str, Any], update_dict: Dict[str, Any]) -> None: + """Deep update dictionary""" + for key, value in update_dict.items(): + if isinstance(value, dict) and key in base_dict and isinstance(base_dict[key], dict): + self._deep_update(base_dict[key], value) + else: + base_dict[key] = value + + async def get_server_config(self) -> Dict[str, Any]: + """Get server-specific configuration""" + config = await self.load_config() + return config.get("server", {}) + + async def get_client_config(self) -> Dict[str, Any]: + """Get client-specific configuration""" + config = await self.load_config() + return config.get("client", {}) + + async def get_api_key(self, provider: str) -> Optional[str]: + """Get API key for a specific provider""" + config = await self.load_config() + api_keys = config.get("api_keys", {}) + return api_keys.get(provider.lower()) + + async def save_config(self, config: Dict[str, Any]) -> None: + """Save configuration to file""" + with open(self._config_file, 'w') as f: + json.dump(config, f, indent=2) + + async def update_config(self, updates: Dict[str, Any]) -> None: + """Update configuration and save""" + config = await self.load_config() + self._deep_update(config, updates) + await self.save_config(config) + self._loaded = False # Force reload on next access diff --git a/src/mcp_template/config/server_config.py b/src/mcp_template/config/server_config.py new file mode 100644 index 0000000..2e9aa1a --- /dev/null +++ b/src/mcp_template/config/server_config.py @@ -0,0 +1,72 @@ +""" +Server Configuration +""" +from typing import Dict, Any, Optional +from dataclasses import dataclass + +from ..core.types import TransportType + + +@dataclass +class ServerConfig: + """Configuration class for MCP servers""" + + name: str = "MCP Server" + version: str = "1.0.0" + transport: TransportType = TransportType.STDIO + host: str = "0.0.0.0" + port: int = 8050 + stateless_http: bool = True + + # Tool configurations + enable_default_tools: bool = True + custom_tools: Optional[Dict[str, Any]] = None + + # Resource configurations + enable_file_resources: bool = False + resource_paths: Optional[list[str]] = None + + # Prompt configurations + enable_default_prompts: bool = False + custom_prompts: Optional[Dict[str, Any]] = None + + # Performance settings + max_concurrent_requests: int = 10 + request_timeout: int = 30 + + def __post_init__(self): + """Initialize optional fields""" + if self.custom_tools is None: + self.custom_tools = {} + if self.resource_paths is None: + self.resource_paths = [] + if self.custom_prompts is None: + self.custom_prompts = {} + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'ServerConfig': + """Create ServerConfig from dictionary""" + # Convert transport string to enum + if 'transport' in config_dict and isinstance(config_dict['transport'], str): + config_dict['transport'] = TransportType(config_dict['transport'].lower()) + + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + result = self.__dict__.copy() + result['transport'] = self.transport.value + return result + + def get_transport_config(self) -> Dict[str, Any]: + """Get transport-specific configuration""" + if self.transport == TransportType.SSE: + return { + "host": self.host, + "port": self.port, + "endpoint": "/sse", + } + elif self.transport == TransportType.STDIO: + return {} + else: + return {} diff --git a/src/mcp_template/config/transport_config.py b/src/mcp_template/config/transport_config.py new file mode 100644 index 0000000..0b71545 --- /dev/null +++ b/src/mcp_template/config/transport_config.py @@ -0,0 +1,89 @@ +""" +Transport Configuration +""" +from typing import Dict, Any, Optional +from dataclasses import dataclass + +from ..core.types import TransportType + + +@dataclass +class TransportConfig: + """Configuration class for MCP transport""" + + transport_type: TransportType = TransportType.STDIO + + # SSE-specific settings + sse_host: str = "localhost" + sse_port: int = 8050 + sse_endpoint: str = "/sse" + sse_timeout: int = 30 + sse_reconnect_delay: float = 1.0 + sse_max_reconnects: int = 5 + + # STDIO-specific settings + stdio_command: Optional[str] = None + stdio_args: Optional[list[str]] = None + stdio_env: Optional[Dict[str, str]] = None + stdio_cwd: Optional[str] = None + + # General transport settings + buffer_size: int = 8192 + encoding: str = "utf-8" + enable_compression: bool = False + enable_ssl: bool = False + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'TransportConfig': + """Create TransportConfig from dictionary""" + # Convert transport string to enum + if 'transport_type' in config_dict and isinstance(config_dict['transport_type'], str): + config_dict['transport_type'] = TransportType(config_dict['transport_type'].lower()) + + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + result = self.__dict__.copy() + result['transport_type'] = self.transport_type.value + return result + + def get_sse_config(self) -> Dict[str, Any]: + """Get SSE-specific configuration""" + return { + "host": self.sse_host, + "port": self.sse_port, + "endpoint": self.sse_endpoint, + "timeout": self.sse_timeout, + "reconnect_delay": self.sse_reconnect_delay, + "max_reconnects": self.sse_max_reconnects, + } + + def get_stdio_config(self) -> Dict[str, Any]: + """Get STDIO-specific configuration""" + config = {} + if self.stdio_command: + config["command"] = self.stdio_command + if self.stdio_args: + config["args"] = self.stdio_args + if self.stdio_env: + config["env"] = self.stdio_env + if self.stdio_cwd: + config["cwd"] = self.stdio_cwd + return config + + def get_transport_config(self) -> Dict[str, Any]: + """Get transport configuration based on type""" + config = { + "buffer_size": self.buffer_size, + "encoding": self.encoding, + "enable_compression": self.enable_compression, + "enable_ssl": self.enable_ssl, + } + + if self.transport_type == TransportType.SSE: + config.update(self.get_sse_config()) + elif self.transport_type == TransportType.STDIO: + config.update(self.get_stdio_config()) + + return config diff --git a/src/mcp_template/core/__init__.py b/src/mcp_template/core/__init__.py new file mode 100644 index 0000000..dc7f94c --- /dev/null +++ b/src/mcp_template/core/__init__.py @@ -0,0 +1,13 @@ +# Core MCP abstractions and base classes +from .types import MCPTool, MCPResource, MCPPrompt, MCPServerConfig +from .interfaces import IMCPServer, IMCPClient, IMCPTransport + +__all__ = [ + 'MCPTool', + 'MCPResource', + 'MCPPrompt', + 'MCPServerConfig', + 'IMCPServer', + 'IMCPClient', + 'IMCPTransport' +] diff --git a/src/mcp_template/core/interfaces.py b/src/mcp_template/core/interfaces.py new file mode 100644 index 0000000..d75cbff --- /dev/null +++ b/src/mcp_template/core/interfaces.py @@ -0,0 +1,181 @@ +""" +Core MCP Interfaces and Abstract Base Classes +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Protocol +from contextlib import asynccontextmanager + +from .types import MCPTool, MCPResource, MCPPrompt, MCPServerConfig + + +class IMCPServer(ABC): + """Abstract base class for MCP servers""" + + @abstractmethod + async def initialize(self) -> None: + """Initialize the MCP server""" + pass + + @abstractmethod + async def register_tool(self, tool: MCPTool) -> None: + """Register a tool with the server""" + pass + + @abstractmethod + async def register_resource(self, resource: MCPResource) -> None: + """Register a resource with the server""" + pass + + @abstractmethod + async def register_prompt(self, prompt: MCPPrompt) -> None: + """Register a prompt with the server""" + pass + + @abstractmethod + async def list_tools(self) -> List[Dict[str, Any]]: + """List all available tools""" + pass + + @abstractmethod + async def list_resources(self) -> List[Dict[str, Any]]: + """List all available resources""" + pass + + @abstractmethod + async def list_prompts(self) -> List[Dict[str, Any]]: + """List all available prompts""" + pass + + @abstractmethod + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any: + """Call a tool by name with arguments""" + pass + + @abstractmethod + async def read_resource(self, uri: str) -> Any: + """Read a resource by URI""" + pass + + @abstractmethod + async def get_prompt(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> str: + """Get a prompt by name with optional arguments""" + pass + + @abstractmethod + async def start(self) -> None: + """Start the MCP server""" + pass + + @abstractmethod + async def stop(self) -> None: + """Stop the MCP server""" + pass + + +class IMCPClient(ABC): + """Abstract base class for MCP clients""" + + @abstractmethod + async def connect(self) -> None: + """Connect to MCP server""" + pass + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from MCP server""" + pass + + @abstractmethod + async def list_tools(self) -> List[Dict[str, Any]]: + """List available tools from server""" + pass + + @abstractmethod + async def list_resources(self) -> List[Dict[str, Any]]: + """List available resources from server""" + pass + + @abstractmethod + async def list_prompts(self) -> List[Dict[str, Any]]: + """List available prompts from server""" + pass + + @abstractmethod + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any: + """Call a tool on the server""" + pass + + @abstractmethod + async def read_resource(self, uri: str) -> Any: + """Read a resource from the server""" + pass + + @abstractmethod + async def get_prompt(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> str: + """Get a prompt from the server""" + pass + + +class IMCPTransport(ABC): + """Abstract base class for MCP transport mechanisms""" + + @abstractmethod + @asynccontextmanager + async def connect(self): + """Establish transport connection""" + pass + + @abstractmethod + async def send_message(self, message: Dict[str, Any]) -> None: + """Send a message through the transport""" + pass + + @abstractmethod + async def receive_message(self) -> Dict[str, Any]: + """Receive a message through the transport""" + pass + + @abstractmethod + async def close(self) -> None: + """Close the transport connection""" + pass + + +class IAIClient(ABC): + """Abstract base class for AI model clients""" + + @property + @abstractmethod + def model_name(self) -> str: + """Get the AI model name""" + pass + + @abstractmethod + async def initialize(self) -> None: + """Initialize the AI client""" + pass + + @abstractmethod + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform a chat completion with optional tools""" + pass + + @abstractmethod + async def process_with_tools( + self, + query: str, + available_tools: List[Dict[str, Any]], + mcp_client: IMCPClient + ) -> str: + """Process a query with MCP tools""" + pass + + @abstractmethod + async def cleanup(self) -> None: + """Clean up resources""" + pass diff --git a/src/mcp_template/core/types.py b/src/mcp_template/core/types.py new file mode 100644 index 0000000..84b0d2b --- /dev/null +++ b/src/mcp_template/core/types.py @@ -0,0 +1,79 @@ +""" +Core MCP Types and Data Structures +""" +from typing import Any, Dict, List, Optional, Protocol, Union +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + + +class TransportType(Enum): + """Supported MCP transport types""" + SSE = "sse" + STDIO = "stdio" + + +@dataclass +class MCPTool: + """Represents an MCP tool that can be called by AI models""" + name: str + description: str + input_schema: Dict[str, Any] + handler: callable + + def __post_init__(self): + """Validate tool configuration""" + if not self.name or not self.description: + raise ValueError("Tool name and description are required") + + +@dataclass +class MCPResource: + """Represents an MCP resource that can be read by clients""" + uri: str + name: str + description: str + mime_type: str + content: Union[str, bytes] + + def __post_init__(self): + """Validate resource configuration""" + if not self.uri or not self.name: + raise ValueError("Resource URI and name are required") + + +@dataclass +class MCPPrompt: + """Represents an MCP prompt template""" + name: str + description: str + template: str + arguments: Optional[Dict[str, Any]] = None + + def __post_init__(self): + """Validate prompt configuration""" + if not self.name or not self.template: + raise ValueError("Prompt name and template are required") + + +@dataclass +class MCPServerConfig: + """Configuration for MCP server""" + name: str + version: str = "1.0.0" + transport: TransportType = TransportType.STDIO + host: str = "0.0.0.0" + port: int = 8050 + tools: List[MCPTool] = None + resources: List[MCPResource] = None + prompts: List[MCPPrompt] = None + stateless_http: bool = True + + def __post_init__(self): + """Initialize empty lists if not provided""" + if self.tools is None: + self.tools = [] + if self.resources is None: + self.resources = [] + if self.prompts is None: + self.prompts = [] diff --git a/src/mcp_template/examples/__init__.py b/src/mcp_template/examples/__init__.py new file mode 100644 index 0000000..f58010e --- /dev/null +++ b/src/mcp_template/examples/__init__.py @@ -0,0 +1,6 @@ +# MCP Template Examples +from .server_examples import ServerExamples +from .client_examples import ClientExamples +from .integration_examples import IntegrationExamples + +__all__ = ['ServerExamples', 'ClientExamples', 'IntegrationExamples'] diff --git a/src/mcp_template/examples/server_examples.py b/src/mcp_template/examples/server_examples.py new file mode 100644 index 0000000..c64106e --- /dev/null +++ b/src/mcp_template/examples/server_examples.py @@ -0,0 +1,174 @@ +""" +Server Examples showing how to use the modular MCP template +""" +import asyncio +from typing import List +from ..core.types import TransportType +from ..server.server_factory import MCPServerFactory +from ..tools.tool_registry import ToolRegistry +from ..resources.data_resources import DataResources + + +class ServerExamples: + """Examples of creating different types of MCP servers""" + + @staticmethod + async def create_math_server(): + """Create a server with only math tools""" + print("🧮 Creating Math Server...") + + # Use tool registry to get math tools + registry = ToolRegistry() + math_tools = registry.get_tools_by_category('math') + + server = MCPServerFactory.create_server( + name="Math Server", + transport=TransportType.STDIO, + tools=math_tools + ) + + print(f"✅ Math Server created with {len(math_tools)} tools") + return server + + @staticmethod + async def create_developer_server(): + """Create a comprehensive developer server""" + print("👨‍💻 Creating Developer Server...") + + registry = ToolRegistry() + tools = registry.get_tools_by_categories(['math', 'text', 'system']) + + # Add data resources + resources = DataResources.get_resources() + + server = MCPServerFactory.create_server( + name="Developer Server", + transport=TransportType.SSE, + host="localhost", + port=8050, + tools=tools, + resources=resources + ) + + print(f"✅ Developer Server created with {len(tools)} tools and {len(resources)} resources") + return server + + @staticmethod + async def create_business_server(): + """Create a business-focused server""" + print("💼 Creating Business Server...") + + registry = ToolRegistry() + + # Add custom business tools + async def calculate_roi(initial_investment: float, final_value: float) -> str: + """Calculate Return on Investment""" + if initial_investment <= 0: + raise ValueError("Initial investment must be positive") + roi = ((final_value - initial_investment) / initial_investment) * 100 + return ".2f" + + async def format_currency(amount: float, currency: str = "USD") -> str: + """Format amount as currency""" + return ",.2f" + + from ..core.types import MCPTool + business_tools = [ + MCPTool( + name="calculate_roi", + description="Calculate Return on Investment percentage", + input_schema={ + "type": "object", + "properties": { + "initial_investment": {"type": "number", "description": "Initial investment amount"}, + "final_value": {"type": "number", "description": "Final value amount"}, + }, + "required": ["initial_investment", "final_value"], + }, + handler=calculate_roi, + ), + MCPTool( + name="format_currency", + description="Format a number as currency", + input_schema={ + "type": "object", + "properties": { + "amount": {"type": "number", "description": "Amount to format"}, + "currency": {"type": "string", "description": "Currency code", "default": "USD"}, + }, + "required": ["amount"], + }, + handler=format_currency, + ), + ] + + # Add to registry + registry.add_custom_tools(business_tools) + + # Get business-relevant tools + all_tools = registry.get_tools_by_categories(['math', 'text']) + all_tools.extend(business_tools) + + server = MCPServerFactory.create_server( + name="Business Server", + transport=TransportType.STDIO, + tools=all_tools + ) + + print(f"✅ Business Server created with {len(all_tools)} tools") + return server + + @staticmethod + async def create_custom_server_with_config(): + """Create a server using configuration""" + print("⚙️ Creating Custom Server with Configuration...") + + config = { + "name": "Custom Config Server", + "transport": "sse", + "host": "localhost", + "port": 8080, + "tools": ["math", "text"], # Tool categories to include + "enable_resources": True, + } + + registry = ToolRegistry() + tools = registry.get_tools_by_categories(config["tools"]) + resources = DataResources.get_resources() if config.get("enable_resources") else [] + + server = MCPServerFactory.create_server( + name=config["name"], + transport=config["transport"], + host=config["host"], + port=config["port"], + tools=tools, + resources=resources + ) + + print(f"✅ Custom Server created with configuration") + print(f" - Tools: {len(tools)}") + print(f" - Resources: {len(resources)}") + print(f" - Transport: {config['transport']}") + return server + + @staticmethod + async def demo_server_lifecycle(): + """Demonstrate complete server lifecycle""" + print("🔄 Server Lifecycle Demo") + + # Create server + server = await ServerExamples.create_math_server() + + # List available tools + tools = await server.list_tools() + print(f"📋 Available tools: {[t['name'] for t in tools]}") + + # Test a tool + try: + result = await server.call_tool("add", {"a": 10, "b": 5}) + print(f"🧮 Tool test - add(10, 5) = {result}") + except Exception as e: + print(f"❌ Tool test failed: {e}") + + print("✅ Server lifecycle demo completed") + return server diff --git a/src/mcp_template/llm_client/__init__.py b/src/mcp_template/llm_client/__init__.py new file mode 100644 index 0000000..89a0471 --- /dev/null +++ b/src/mcp_template/llm_client/__init__.py @@ -0,0 +1,14 @@ +# AI Client implementations +from .base_client import BaseAIClient +from .openai_client import OpenAIClient +from .claude_client import ClaudeClient +from .grok_client import GrokClient +from .client_factory import AIClientFactory + +__all__ = [ + 'BaseAIClient', + 'OpenAIClient', + 'ClaudeClient', + 'GrokClient', + 'AIClientFactory' +] diff --git a/src/mcp_template/llm_client/base_client.py b/src/mcp_template/llm_client/base_client.py new file mode 100644 index 0000000..0bd7b22 --- /dev/null +++ b/src/mcp_template/llm_client/base_client.py @@ -0,0 +1,150 @@ +""" +Base AI Client with common MCP integration functionality +""" +import json +import os +import sys +from typing import Any, Dict, List, Optional +from abc import ABC + +# Add the project root to the path to import config +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +try: + from config import Config + CONFIG_AVAILABLE = True +except ImportError: + CONFIG_AVAILABLE = False + +from ..core.interfaces import IAIClient, IMCPClient + + +class BaseAIClient(IAIClient, ABC): + """Base class for AI clients with MCP integration""" + + def __init__(self, model_name: str, provider: str, api_key: Optional[str] = None, **kwargs): + self._model_name = model_name + self._provider = provider + self._client = None + self._initialized = False + self._extra_config = kwargs + + # Get API key from config if not provided + if api_key is None and CONFIG_AVAILABLE: + api_key = self._get_api_key_from_config() + + if not api_key: + raise ValueError(f"API key not provided and could not be loaded from config for provider: {provider}") + + self._api_key = api_key + + def _get_api_key_from_config(self) -> Optional[str]: + """Get API key from config based on provider""" + if not CONFIG_AVAILABLE: + return None + + provider_key_map = { + "openai": Config.OPENAI_API_KEY, + "claude": Config.CLAUDE_API_KEY, + "grok": Config.GROK_API_KEY + } + + return provider_key_map.get(self._provider) + + @property + def model_name(self) -> str: + """Get the AI model name""" + return self._model_name + + async def initialize(self) -> None: + """Initialize the AI client - to be implemented by subclasses""" + if self._initialized: + return + await self._initialize_client() + self._initialized = True + + async def _initialize_client(self) -> None: + """Initialize the specific AI client - to be implemented by subclasses""" + pass + + async def process_with_tools( + self, + query: str, + available_tools: List[Dict[str, Any]], + mcp_client: IMCPClient + ) -> str: + """Process a query with MCP tools using a common pattern""" + + # Format tools for the specific AI provider + formatted_tools = self._format_tools_for_provider(available_tools) + + # Create initial messages + messages = [{"role": "user", "content": query}] + + # Get AI response with tool calling + response = await self.chat_completion( + messages=messages, + tools=formatted_tools, + tool_choice="auto" + ) + + # Extract assistant message + assistant_message = response["choices"][0]["message"] + + # Check if tools were called + if "tool_calls" in assistant_message and assistant_message["tool_calls"]: + # Add assistant message to conversation + messages.append(assistant_message) + + # Process each tool call + for tool_call in assistant_message["tool_calls"]: + try: + # Extract tool call details + tool_name = tool_call["function"]["name"] + tool_args = json.loads(tool_call["function"]["arguments"]) + + # Call the tool via MCP client + tool_result = await mcp_client.call_tool(tool_name, tool_args) + + # Add tool response to conversation + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": str(tool_result), + }) + + except Exception as e: + # Handle tool call errors + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": f"Error calling tool: {str(e)}", + }) + + # Get final response from AI with tool results + final_response = await self.chat_completion( + messages=messages, + tools=formatted_tools, + tool_choice="none" # Don't allow more tool calls + ) + + return final_response["choices"][0]["message"]["content"] + + # No tools called, return direct response + return assistant_message["content"] + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for the specific AI provider - to be implemented by subclasses""" + return tools + + async def cleanup(self) -> None: + """Clean up resources""" + if self._client: + await self._cleanup_client() + self._initialized = False + + async def _cleanup_client(self) -> None: + """Clean up the specific AI client - to be implemented by subclasses""" + pass diff --git a/src/mcp_template/llm_client/claude_client.py b/src/mcp_template/llm_client/claude_client.py new file mode 100644 index 0000000..ce5cbb1 --- /dev/null +++ b/src/mcp_template/llm_client/claude_client.py @@ -0,0 +1,56 @@ +""" +Claude Client Implementation (Placeholder) +""" +from typing import Any, Dict, List, Optional + +# Placeholder for Claude/Anthropic client +# This would need the actual Anthropic SDK when implemented + +from .base_client import BaseAIClient + + +class ClaudeClient(BaseAIClient): + """Claude client with MCP integration (Placeholder Implementation)""" + + def __init__( + self, + model_name: str = "claude-3-opus-20240229", + api_key: Optional[str] = None, + **kwargs + ): + # Note: This is a placeholder. You'll need to install the Anthropic SDK + # pip install anthropic + super().__init__(model_name, "claude", api_key, **kwargs) + + # Claude specific configuration + self._temperature = kwargs.get("temperature", 0.7) + self._max_tokens = kwargs.get("max_tokens", 1000) + + async def _initialize_client(self) -> None: + """Initialize the Claude client""" + # TODO: Implement with actual Anthropic SDK + # self._client = Anthropic(api_key=self._api_key) + raise NotImplementedError("Claude client not yet implemented. Install Anthropic SDK and implement.") + + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform Claude chat completion""" + if not self._initialized: + await self.initialize() + + # TODO: Implement Claude API call + raise NotImplementedError("Claude chat completion not yet implemented") + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for Claude's expected format""" + # TODO: Implement Claude tool formatting + return tools + + async def _cleanup_client(self) -> None: + """Clean up Claude client""" + # TODO: Implement cleanup + pass diff --git a/src/mcp_template/llm_client/client_factory.py b/src/mcp_template/llm_client/client_factory.py new file mode 100644 index 0000000..c12521c --- /dev/null +++ b/src/mcp_template/llm_client/client_factory.py @@ -0,0 +1,104 @@ +""" +AI Client Factory for easy client creation and management +""" +import os +import sys +from typing import Optional, Dict, Any + +# Add the project root to the path to import config +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +try: + from config import Config + CONFIG_AVAILABLE = True +except ImportError: + CONFIG_AVAILABLE = False + +from .base_client import BaseAIClient +from .openai_client import OpenAIClient +from .claude_client import ClaudeClient +from .grok_client import GrokClient + + +class AIClientFactory: + """Factory class for creating AI clients with different providers""" + + @staticmethod + def create_client( + provider: str, + model_name: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs + ) -> BaseAIClient: + """Create an AI client for the specified provider""" + + # Set default model names if not provided + if model_name is None: + if provider.lower() == "openai": + model_name = "gpt-4o" + elif provider.lower() == "claude": + model_name = "claude-3-opus-20240229" + elif provider.lower() == "grok": + model_name = "grok-1" + + # Get API key from config if not provided + if api_key is None and CONFIG_AVAILABLE: + provider_key_map = { + "openai": Config.OPENAI_API_KEY, + "claude": Config.CLAUDE_API_KEY, + "grok": Config.GROK_API_KEY + } + api_key = provider_key_map.get(provider.lower()) + + if not api_key: + raise ValueError(f"API key not provided and could not be loaded from config for provider: {provider}") + + # Create the appropriate client + provider_lower = provider.lower() + if provider_lower == "openai": + return OpenAIClient(model_name, api_key, **kwargs) + elif provider_lower == "claude": + return ClaudeClient(model_name, api_key, **kwargs) + elif provider_lower == "grok": + return GrokClient(model_name, api_key, **kwargs) + else: + raise ValueError(f"Unsupported AI provider: {provider}") + + @staticmethod + def create_openai_client( + model_name: str = "gpt-4o", + api_key: Optional[str] = None, + **kwargs + ) -> OpenAIClient: + """Create an OpenAI client""" + return AIClientFactory.create_client("openai", model_name, api_key, **kwargs) + + @staticmethod + def create_claude_client( + model_name: str = "claude-3-opus-20240229", + api_key: Optional[str] = None, + **kwargs + ) -> ClaudeClient: + """Create a Claude client""" + return AIClientFactory.create_client("claude", model_name, api_key, **kwargs) + + @staticmethod + def create_grok_client( + model_name: str = "grok-1", + api_key: Optional[str] = None, + **kwargs + ) -> GrokClient: + """Create a Grok client""" + return AIClientFactory.create_client("grok", model_name, api_key, **kwargs) + + @staticmethod + def get_available_providers() -> list[str]: + """Get list of available AI providers""" + return ["openai", "claude", "grok"] + + @staticmethod + def validate_provider(provider: str) -> bool: + """Validate if a provider is supported""" + return provider.lower() in AIClientFactory.get_available_providers() diff --git a/src/mcp_template/llm_client/grok_client.py b/src/mcp_template/llm_client/grok_client.py new file mode 100644 index 0000000..0bb1968 --- /dev/null +++ b/src/mcp_template/llm_client/grok_client.py @@ -0,0 +1,55 @@ +""" +Grok Client Implementation (Placeholder) +""" +from typing import Any, Dict, List, Optional + +# Placeholder for Grok/xAI client +# This would need the xAI SDK or direct API integration when implemented + +from .base_client import BaseAIClient + + +class GrokClient(BaseAIClient): + """Grok client with MCP integration (Placeholder Implementation)""" + + def __init__( + self, + model_name: str = "grok-1", + api_key: Optional[str] = None, + **kwargs + ): + # Note: This is a placeholder. You'll need xAI API integration + super().__init__(model_name, "grok", api_key, **kwargs) + + # Grok specific configuration + self._temperature = kwargs.get("temperature", 0.7) + self._max_tokens = kwargs.get("max_tokens", 1000) + + async def _initialize_client(self) -> None: + """Initialize the Grok client""" + # TODO: Implement with xAI API or SDK + # This might require direct HTTP calls to xAI API + raise NotImplementedError("Grok client not yet implemented. Implement xAI API integration.") + + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform Grok chat completion""" + if not self._initialized: + await self.initialize() + + # TODO: Implement Grok API call + raise NotImplementedError("Grok chat completion not yet implemented") + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for Grok's expected format""" + # TODO: Implement Grok tool formatting + return tools + + async def _cleanup_client(self) -> None: + """Clean up Grok client""" + # TODO: Implement cleanup + pass diff --git a/src/mcp_template/llm_client/openai_client.py b/src/mcp_template/llm_client/openai_client.py new file mode 100644 index 0000000..a9b9980 --- /dev/null +++ b/src/mcp_template/llm_client/openai_client.py @@ -0,0 +1,105 @@ +""" +OpenAI Client Implementation +""" +from typing import Any, Dict, List, Optional + +try: + from openai import AsyncOpenAI + OPENAI_AVAILABLE = True +except ImportError: + OPENAI_AVAILABLE = False + +from .base_client import BaseAIClient +from config import Config + +class OpenAIClient(BaseAIClient): + """OpenAI client with MCP integration""" + + def __init__( + self, + model_name: str = "gpt-4o", + api_key: Optional[str] = None, + **kwargs + ): + if not OPENAI_AVAILABLE: + raise ImportError("OpenAI package not installed. Install with: pip install openai") + + super().__init__(model_name, "openai", api_key, **kwargs) + + # OpenAI specific configuration + self._temperature = kwargs.get("temperature", 0.7) + self._max_tokens = kwargs.get("max_tokens", 1000) + self._api_key = api_key or Config.OPENAI_API_KEY + + async def _initialize_client(self) -> None: + """Initialize the OpenAI client""" + self._client = AsyncOpenAI(api_key=self._api_key) + + async def chat_completion( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs + ) -> Dict[str, Any]: + """Perform OpenAI chat completion""" + if not self._initialized: + await self.initialize() + + # Prepare request parameters + request_params = { + "model": self._model_name, + "messages": messages, + "temperature": self._temperature, + "max_tokens": self._max_tokens, + } + + # Add tools if provided + if tools: + request_params["tools"] = tools + request_params["tool_choice"] = kwargs.get("tool_choice", "auto") + + # Make the API call + response = await self._client.chat.completions.create(**request_params) + + # Convert to standard format + return { + "choices": [ + { + "message": { + "role": choice.message.role, + "content": choice.message.content, + "tool_calls": [ + { + "id": tool_call.id, + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + } + } + for tool_call in (choice.message.tool_calls or []) + ] if choice.message.tool_calls else None, + } + } + for choice in response.choices + ] + } + + def _format_tools_for_provider(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format tools for OpenAI's expected format""" + formatted_tools = [] + for tool in tools: + formatted_tool = { + "type": "function", + "function": { + "name": tool["name"], + "description": tool["description"], + "parameters": tool["inputSchema"], + } + } + formatted_tools.append(formatted_tool) + return formatted_tools + + async def _cleanup_client(self) -> None: + """Clean up OpenAI client""" + if self._client: + await self._client.close() diff --git a/src/mcp_template/mcp_client.py b/src/mcp_template/mcp_client.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_template/server/__init__.py b/src/mcp_template/server/__init__.py new file mode 100644 index 0000000..297e1c8 --- /dev/null +++ b/src/mcp_template/server/__init__.py @@ -0,0 +1,5 @@ +# MCP Server implementations +from .modular_server import ModularMCPServer +from .server_factory import MCPServerFactory + +__all__ = ['ModularMCPServer', 'MCPServerFactory'] diff --git a/src/mcp_template/server/modular_server.py b/src/mcp_template/server/modular_server.py new file mode 100644 index 0000000..f606fa6 --- /dev/null +++ b/src/mcp_template/server/modular_server.py @@ -0,0 +1,174 @@ +""" +Modular MCP Server Implementation +""" +import os +from mcp.server.fastmcp import FastMCP +from typing import Optional, List +from .tools.tool_registry import ServerToolRegistry +from .prompts.prompt_registry import ServerPromptRegistry +from .resources.resource_registry import ServerResourceRegistry + + +class ModularMCPServer: + """Modular MCP Server that automatically discovers and registers tools, prompts, and resources""" + + def __init__( + self, + name: str, + host: str = "0.0.0.0", + port: int = 8050, + stateless_http: bool = True, + tools_directory: Optional[str] = None, + prompts_directory: Optional[str] = None, + resources_directory: Optional[str] = None + ): + self.name = name + self.host = host + self.port = port + self.stateless_http = stateless_http + + # Initialize registries + self.tool_registry = ServerToolRegistry(tools_directory) + self.prompt_registry = ServerPromptRegistry(prompts_directory) + self.resource_registry = ServerResourceRegistry(resources_directory) + + # Create FastMCP server + self.mcp = FastMCP( + name=name, + host=host, + port=port, + stateless_http=stateless_http + ) + + self._initialized = False + + async def initialize(self) -> None: + """Initialize the server and register all components""" + if self._initialized: + return + + print(f"Initializing {self.name} server...") + + # Discover and register tools + print("Discovering tools...") + self.tool_registry.register_tools_with_server(self.mcp) + tool_count = len(self.tool_registry.get_all_tools()) + print(f"Registered {tool_count} tools") + + # Discover and register prompts + print("Discovering prompts...") + self.prompt_registry.register_prompts_with_server(self.mcp) + prompt_count = len(self.prompt_registry.get_all_prompts()) + print(f"Registered {prompt_count} prompts") + + # Discover and register resources + print("Discovering resources...") + self.resource_registry.register_resources_with_server(self.mcp) + resource_count = len(self.resource_registry.get_all_resources()) + print(f"Registered {resource_count} resources") + + self._initialized = True + print(f"Server initialization complete!") + + def run(self, transport: str = "stdio") -> None: + """Run the server with the specified transport""" + if not self._initialized: + import asyncio + asyncio.run(self.initialize()) + + print(f"Starting {self.name} server with {transport} transport...") + self.mcp.run(transport=transport) + + def get_server_info(self) -> dict: + """Get information about the server and its components""" + return { + "name": self.name, + "host": self.host, + "port": self.port, + "tools": { + "count": len(self.tool_registry.get_all_tools()), + "names": self.tool_registry.get_tool_names() + }, + "prompts": { + "count": len(self.prompt_registry.get_all_prompts()), + "names": self.prompt_registry.get_prompt_names() + }, + "resources": { + "count": len(self.resource_registry.get_all_resources()), + "uris": self.resource_registry.get_resource_uris() + } + } + + +def create_default_server(transport: str = "stdio", port: int = 8050) -> "ModularMCPServer": + """Create a default modular server with auto-discovery""" + # Get the directory paths for tools, prompts, and resources + # Start from the mcp_template package directory + base_dir = os.path.dirname(os.path.dirname(__file__)) + tools_dir = os.path.join(base_dir, "tools") + prompts_dir = os.path.join(base_dir, "server", "prompts") + resources_dir = os.path.join(base_dir, "server", "resources") + + server = ModularMCPServer( + name="MCP Modular Server", + host="0.0.0.0", + port=port, + stateless_http=True, + tools_directory=tools_dir, + prompts_directory=prompts_dir, + resources_directory=resources_dir + ) + return server + + +def run_server(transport: str = "stdio") -> None: + """Run the modular MCP server with the specified transport method""" + supported_transports = ["stdio", "sse", "streamable-http"] + + if transport not in supported_transports: + raise ValueError(f"Unknown transport: {transport}. Supported: {supported_transports}") + + print(f" Starting MCP Modular Server with {transport.upper()} transport") + + # Create and run the server + server = create_default_server(transport) + + if transport == "stdio": + print(" Server ready for stdio communication") + elif transport == "sse": + print("Server ready for SSE communication on http://0.0.0.0:8050/sse") + elif transport == "streamable-http": + print("Server ready for Streamable HTTP communication") + + # Run the server + server.run(transport) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Run MCP Modular Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python modular_server.py # Run with stdio (default) + python modular_server.py --transport stdio # Run with stdio explicitly + python modular_server.py --transport sse # Run with SSE transport + python modular_server.py --transport streamable-http # Run with Streamable HTTP + +The server will automatically discover and register: +- Tools from the tools directory +- Prompts from the prompts directory +- Resources from the resources directory + """ + ) + parser.add_argument( + "--transport", + choices=["stdio", "sse", "streamable-http"], + default="stdio", + help="Transport method to use (default: stdio)" + ) + + args = parser.parse_args() + run_server(args.transport) diff --git a/src/mcp_template/server/prompts/__init__.py b/src/mcp_template/server/prompts/__init__.py new file mode 100644 index 0000000..de4d4a0 --- /dev/null +++ b/src/mcp_template/server/prompts/__init__.py @@ -0,0 +1,7 @@ +""" +Server Prompts Module +""" +from .base_prompt import BaseServerPrompt +from .prompt_registry import ServerPromptRegistry + +__all__ = ['BaseServerPrompt', 'ServerPromptRegistry'] diff --git a/src/mcp_template/server/prompts/base_prompt.py b/src/mcp_template/server/prompts/base_prompt.py new file mode 100644 index 0000000..9dd96e5 --- /dev/null +++ b/src/mcp_template/server/prompts/base_prompt.py @@ -0,0 +1,47 @@ +""" +Base Server Prompt Class +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class BaseServerPrompt(ABC): + """Base class for server prompts that can be registered with FastMCP""" + + def __init__(self, name: str, description: str, template: str, arguments: Optional[Dict[str, Any]] = None): + self.name = name + self.description = description + self.template = template + self.arguments = arguments or {} + + @abstractmethod + async def generate(self, **kwargs) -> str: + """Generate the prompt with the provided arguments""" + pass + + def get_prompt_definition(self) -> Dict[str, Any]: + """Get the prompt definition for FastMCP registration""" + return { + "name": self.name, + "description": self.description, + "arguments": self.arguments + } + + def create_fastmcp_prompt(self, mcp_server): + """Create a FastMCP prompt decorator for this prompt""" + @mcp_server.prompt(name=self.name, description=self.description) + async def prompt_wrapper(**kwargs): + return await self.generate(**kwargs) + + return prompt_wrapper + + def _substitute_template(self, **kwargs) -> str: + """Helper method to substitute variables in template""" + result = self.template + + # Apply provided arguments + for key, value in kwargs.items(): + placeholder = f"{{{key}}}" + result = result.replace(placeholder, str(value)) + + return result diff --git a/src/mcp_template/server/prompts/greeting_prompt.py b/src/mcp_template/server/prompts/greeting_prompt.py new file mode 100644 index 0000000..c821c1d --- /dev/null +++ b/src/mcp_template/server/prompts/greeting_prompt.py @@ -0,0 +1,44 @@ +""" +Greeting Prompt Example +""" +from mcp_template.server.prompts.base_prompt import BaseServerPrompt + + +class GreetingPrompt(BaseServerPrompt): + """A greeting prompt template""" + + def __init__(self): + super().__init__( + name="greeting_prompt", + description="Generate a greeting prompt for AI models", + template="Please write a {style} greeting for someone named {name}. The greeting should be {tone} and include a {element}.", + arguments={ + "style": { + "type": "string", + "enum": ["friendly", "formal", "casual"], + "description": "Style of greeting", + "default": "friendly" + }, + "tone": { + "type": "string", + "enum": ["warm", "professional", "relaxed"], + "description": "Tone of the greeting", + "default": "warm" + }, + "element": { + "type": "string", + "enum": ["compliment", "question", "observation"], + "description": "Element to include in greeting", + "default": "compliment" + } + } + ) + + async def generate(self, name: str, style: str = "friendly", tone: str = "warm", element: str = "compliment") -> str: + """Generate the greeting prompt""" + return self._substitute_template( + name=name, + style=style, + tone=tone, + element=element + ) diff --git a/src/mcp_template/server/prompts/prompt_registry.py b/src/mcp_template/server/prompts/prompt_registry.py new file mode 100644 index 0000000..6e49fa9 --- /dev/null +++ b/src/mcp_template/server/prompts/prompt_registry.py @@ -0,0 +1,185 @@ +""" +Server Prompt Registry for dynamic prompt registration +""" +import os +import importlib +import inspect +from typing import List, Dict, Any, Type, Optional +from pathlib import Path +from mcp_template.server.prompts.base_prompt import BaseServerPrompt + + +class ServerPromptRegistry: + """Registry for managing server prompts from files""" + + def __init__(self, prompts_directory: str = None): + self.prompts_directory = prompts_directory or os.path.dirname(__file__) + self._registered_prompts: Dict[str, BaseServerPrompt] = {} + self._prompt_files: List[str] = [] + + @property + def directory(self): + """Alias for prompts_directory for backward compatibility""" + return self.prompts_directory + + @property + def _prompts(self): + """Alias for _registered_prompts for backward compatibility""" + return self._registered_prompts + + def discover_prompts(self) -> List[BaseServerPrompt]: + """Discover all prompts in the prompts directory""" + prompts = [] + prompts_dir = Path(self.prompts_directory) + + if not prompts_dir.exists(): + return prompts + + # Find all Python files in the prompts directory + for file_path in prompts_dir.glob("*.py"): + if file_path.name.startswith("__"): + continue + + module_name = file_path.stem + try: + # Import the module + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # First, look for BaseServerPrompt subclasses (class-based approach) + for name, obj in inspect.getmembers(module, inspect.isclass): + if (issubclass(obj, BaseServerPrompt) and + obj != BaseServerPrompt and + not inspect.isabstract(obj)): + try: + prompt_instance = obj() + prompts.append(prompt_instance) + self._registered_prompts[prompt_instance.name] = prompt_instance + except Exception as e: + print(f"Warning: Could not instantiate prompt {name}: {e}") + + # Second, look for functions decorated with @mcp.prompt or ending with _prompt + for name, obj in inspect.getmembers(module): + if (inspect.isfunction(obj) and + hasattr(obj, '__name__') and + not name.startswith('_')): + try: + # Check if it's decorated with mcp.prompt or follows naming convention + if (name.endswith('_prompt') or + hasattr(obj, '__mcp_prompt__') or + hasattr(obj, '__wrapped__')): # Often indicates decoration + # Convert function to BaseServerPrompt wrapper + wrapper = self._create_prompt_wrapper(obj, name) + prompts.append(wrapper) + self._registered_prompts[name] = wrapper + except Exception as e: + continue + + # Third, look for classes with get_prompts() static method + for name, obj in inspect.getmembers(module, inspect.isclass): + if hasattr(obj, 'get_prompts') and callable(getattr(obj, 'get_prompts')): + try: + # Call get_prompts() to get prompt objects + prompt_objects = obj.get_prompts() + if isinstance(prompt_objects, list): + for prompt in prompt_objects: + if hasattr(prompt, 'name'): + wrapper = self._create_prompt_wrapper_from_object(prompt) + prompts.append(wrapper) + self._registered_prompts[prompt.name] = wrapper + except Exception as e: + print(f"Warning: Could not get prompts from {name}: {e}") + + except Exception as e: + print(f"Warning: Could not load prompt file {file_path}: {e}") + + return prompts + + def _create_prompt_wrapper(self, func, name: str) -> BaseServerPrompt: + """Convert a function to a BaseServerPrompt wrapper""" + from mcp_template.server.prompts.base_prompt import BaseServerPrompt + + class PromptWrapper(BaseServerPrompt): + def __init__(self, func, name): + # Extract arguments from function signature + import inspect as insp + sig = insp.signature(func) + arguments = {} + for param_name, param in sig.parameters.items(): + if param_name != 'self': # Skip self for methods + arguments[param_name] = { + "type": "string", # Default to string, could be enhanced + "description": f"Parameter {param_name}" + } + + super().__init__( + name=name, + description=getattr(func, '__doc__', f'Prompt: {name}'), + arguments=arguments + ) + self._func = func + + def get_messages(self, **kwargs): + # Call the function with provided arguments + return self._func(**kwargs) + + return PromptWrapper(func, name) + + def _create_prompt_wrapper_from_object(self, prompt_obj) -> BaseServerPrompt: + """Convert a prompt object to BaseServerPrompt wrapper""" + from mcp_template.server.prompts.base_prompt import BaseServerPrompt + + class PromptObjectWrapper(BaseServerPrompt): + def __init__(self, prompt_obj): + super().__init__( + name=getattr(prompt_obj, 'name', 'unknown'), + description=getattr(prompt_obj, 'description', ''), + arguments=getattr(prompt_obj, 'arguments', {}) + ) + self._prompt_obj = prompt_obj + + def get_messages(self, **kwargs): + # Try to call the prompt object + if hasattr(self._prompt_obj, 'get_messages'): + return self._prompt_obj.get_messages(**kwargs) + elif callable(self._prompt_obj): + return self._prompt_obj(**kwargs) + return [] + + return PromptObjectWrapper(prompt_obj) + + def register_prompt(self, prompt: BaseServerPrompt) -> None: + """Register a single prompt""" + self._registered_prompts[prompt.name] = prompt + + def register_prompts(self, prompts: List[BaseServerPrompt]) -> None: + """Register multiple prompts""" + for prompt in prompts: + self.register_prompt(prompt) + + def get_prompt(self, name: str) -> Optional[BaseServerPrompt]: + """Get a prompt by name""" + return self._registered_prompts.get(name) + + def get_all_prompts(self) -> List[BaseServerPrompt]: + """Get all registered prompts""" + return list(self._registered_prompts.values()) + + def get_prompt_names(self) -> List[str]: + """Get all registered prompt names""" + return list(self._registered_prompts.keys()) + + def register_prompts_with_server(self, mcp_server) -> None: + """Register all discovered prompts with a FastMCP server""" + # First discover prompts if not already done + if not self._registered_prompts: + self.discover_prompts() + + # Register each prompt with the server + for prompt in self._registered_prompts.values(): + prompt.create_fastmcp_prompt(mcp_server) + + def clear_prompts(self) -> None: + """Clear all registered prompts""" + self._registered_prompts.clear() diff --git a/src/mcp_template/server/resources/__init__.py b/src/mcp_template/server/resources/__init__.py new file mode 100644 index 0000000..d6b9d57 --- /dev/null +++ b/src/mcp_template/server/resources/__init__.py @@ -0,0 +1,7 @@ +""" +Server Resources Module +""" +from .base_resource import BaseServerResource +from .resource_registry import ServerResourceRegistry + +__all__ = ['BaseServerResource', 'ServerResourceRegistry'] diff --git a/src/mcp_template/server/resources/base_resource.py b/src/mcp_template/server/resources/base_resource.py new file mode 100644 index 0000000..37256d7 --- /dev/null +++ b/src/mcp_template/server/resources/base_resource.py @@ -0,0 +1,37 @@ +""" +Base Server Resource Class +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Union + + +class BaseServerResource(ABC): + """Base class for server resources that can be registered with FastMCP""" + + def __init__(self, uri: str, name: str, description: str, mime_type: str = "text/plain"): + self.uri = uri + self.name = name + self.description = description + self.mime_type = mime_type + + @abstractmethod + async def read(self, **kwargs) -> Union[str, bytes]: + """Read the resource content""" + pass + + def get_resource_definition(self) -> Dict[str, Any]: + """Get the resource definition for FastMCP registration""" + return { + "uri": self.uri, + "name": self.name, + "description": self.description, + "mime_type": self.mime_type + } + + def create_fastmcp_resource(self, mcp_server): + """Create a FastMCP resource decorator for this resource""" + @mcp_server.resource(self.uri, name=self.name, description=self.description, mime_type=self.mime_type) + async def resource_wrapper(): + return await self.read() + + return resource_wrapper diff --git a/src/mcp_template/server/resources/config_resource.py b/src/mcp_template/server/resources/config_resource.py new file mode 100644 index 0000000..fb4e200 --- /dev/null +++ b/src/mcp_template/server/resources/config_resource.py @@ -0,0 +1,37 @@ +""" +Configuration Resource Example +""" +from mcp_template.server.resources.base_resource import BaseServerResource + + +class ConfigResource(BaseServerResource): + """A configuration resource that provides server settings""" + + def __init__(self): + super().__init__( + uri="config://settings", + name="Server Configuration", + description="Get the current server configuration settings", + mime_type="application/json" + ) + + async def read(self, **kwargs) -> str: + """Get the configuration content""" + import json + config = { + "server_name": "MCP Template Server", + "version": "1.0.0", + "features": [ + "tools", + "prompts", + "resources" + ], + "transport": { + "supported": ["stdio", "sse"], + "default": "stdio" + }, + "tools_count": 2, + "prompts_count": 1, + "resources_count": 1 + } + return json.dumps(config, indent=2) diff --git a/src/mcp_template/server/resources/dynamic_resource.py b/src/mcp_template/server/resources/dynamic_resource.py new file mode 100644 index 0000000..90585da --- /dev/null +++ b/src/mcp_template/server/resources/dynamic_resource.py @@ -0,0 +1,28 @@ +""" +Dynamic Resource Example +""" +from mcp_template.server.resources.base_resource import BaseServerResource + + +class DynamicResource(BaseServerResource): + """A dynamic resource that accepts parameters""" + + def __init__(self): + super().__init__( + uri="dynamic://greeting", + name="Dynamic Greeting", + description="Get a personalized greeting resource", + mime_type="text/plain" + ) + + async def read(self, name: str = "World") -> str: + """Get the dynamic greeting content""" + return f"Hello, {name}! This is a dynamic resource that was generated just for you.\n\n" \ + f"Resource URI: {self.uri}\n" \ + f"Generated at: {self._get_timestamp()}\n" \ + f"Personalized for: {name}" + + def _get_timestamp(self) -> str: + """Get current timestamp""" + from datetime import datetime + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") diff --git a/src/mcp_template/server/resources/resource_registry.py b/src/mcp_template/server/resources/resource_registry.py new file mode 100644 index 0000000..70fed66 --- /dev/null +++ b/src/mcp_template/server/resources/resource_registry.py @@ -0,0 +1,179 @@ +""" +Server Resource Registry for dynamic resource registration +""" +import os +import importlib +import inspect +from typing import List, Dict, Any, Type,Optional +from pathlib import Path +from mcp_template.server.resources.base_resource import BaseServerResource + + +class ServerResourceRegistry: + """Registry for managing server resources from files""" + + def __init__(self, resources_directory: str = None): + self.resources_directory = resources_directory or os.path.dirname(__file__) + self._registered_resources: Dict[str, BaseServerResource] = {} + self._resource_files: List[str] = [] + + @property + def directory(self): + """Alias for resources_directory for backward compatibility""" + return self.resources_directory + + @property + def _resources(self): + """Alias for _registered_resources for backward compatibility""" + return self._registered_resources + + def discover_resources(self) -> List[BaseServerResource]: + """Discover all resources in the resources directory""" + resources = [] + resources_dir = Path(self.resources_directory) + + if not resources_dir.exists(): + return resources + + # Find all Python files in the resources directory + for file_path in resources_dir.glob("*.py"): + if file_path.name.startswith("__"): + continue + + module_name = file_path.stem + try: + # Import the module + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # First, look for BaseServerResource subclasses (class-based approach) + for name, obj in inspect.getmembers(module, inspect.isclass): + if (issubclass(obj, BaseServerResource) and + obj != BaseServerResource and + not inspect.isabstract(obj)): + try: + resource_instance = obj() + resources.append(resource_instance) + self._registered_resources[resource_instance.uri] = resource_instance + except Exception as e: + print(f"Warning: Could not instantiate resource {name}: {e}") + + # Second, look for functions decorated with @mcp.resource or ending with _resource + for name, obj in inspect.getmembers(module): + if (inspect.isfunction(obj) and + hasattr(obj, '__name__') and + not name.startswith('_')): + try: + # Check if it's decorated with mcp.resource or follows naming convention + if (name.endswith('_resource') or + hasattr(obj, '__mcp_resource__') or + hasattr(obj, '__wrapped__')): # Often indicates decoration + # Convert function to BaseServerResource wrapper + wrapper = self._create_resource_wrapper(obj, name) + resources.append(wrapper) + self._registered_resources[wrapper.uri] = wrapper + except Exception as e: + continue + + # Third, look for classes with get_resources() static method + for name, obj in inspect.getmembers(module, inspect.isclass): + if hasattr(obj, 'get_resources') and callable(getattr(obj, 'get_resources')): + try: + # Call get_resources() to get resource objects + resource_objects = obj.get_resources() + if isinstance(resource_objects, list): + for resource in resource_objects: + if hasattr(resource, 'uri'): + wrapper = self._create_resource_wrapper_from_object(resource) + resources.append(wrapper) + self._registered_resources[resource.uri] = wrapper + except Exception as e: + print(f"Warning: Could not get resources from {name}: {e}") + + except Exception as e: + print(f"Warning: Could not load resource file {file_path}: {e}") + + return resources + + def _create_resource_wrapper(self, func, name: str) -> BaseServerResource: + """Convert a function to a BaseServerResource wrapper""" + from mcp_template.server.resources.base_resource import BaseServerResource + + class ResourceWrapper(BaseServerResource): + def __init__(self, func, name): + # Create a URI pattern from the function name + uri = f"resource://{name}" + + super().__init__( + uri=uri, + name=name, + description=getattr(func, '__doc__', f'Resource: {name}'), + mime_type="text/plain" # Default, could be enhanced + ) + self._func = func + + async def read(self): + # Call the function to get resource content + return str(self._func()) + + return ResourceWrapper(func, name) + + def _create_resource_wrapper_from_object(self, resource_obj) -> BaseServerResource: + """Convert a resource object to BaseServerResource wrapper""" + from mcp_template.server.resources.base_resource import BaseServerResource + + class ResourceObjectWrapper(BaseServerResource): + def __init__(self, resource_obj): + super().__init__( + uri=getattr(resource_obj, 'uri', 'unknown://resource'), + name=getattr(resource_obj, 'name', 'unknown'), + description=getattr(resource_obj, 'description', ''), + mime_type=getattr(resource_obj, 'mime_type', 'text/plain') + ) + self._resource_obj = resource_obj + + async def read(self, **kwargs): + # Try to call the resource object's read method + if hasattr(self._resource_obj, 'read'): + return await self._resource_obj.read(**kwargs) + elif callable(self._resource_obj): + return str(self._resource_obj()) + return "Resource content not available" + + return ResourceObjectWrapper(resource_obj) + + def register_resource(self, resource: BaseServerResource) -> None: + """Register a single resource""" + self._registered_resources[resource.uri] = resource + + def register_resources(self, resources: List[BaseServerResource]) -> None: + """Register multiple resources""" + for resource in resources: + self.register_resource(resource) + + def get_resource(self, uri: str) -> Optional[BaseServerResource]: + """Get a resource by URI""" + return self._registered_resources.get(uri) + + def get_all_resources(self) -> List[BaseServerResource]: + """Get all registered resources""" + return list(self._registered_resources.values()) + + def get_resource_uris(self) -> List[str]: + """Get all registered resource URIs""" + return list(self._registered_resources.keys()) + + def register_resources_with_server(self, mcp_server) -> None: + """Register all discovered resources with a FastMCP server""" + # First discover resources if not already done + if not self._registered_resources: + self.discover_resources() + + # Register each resource with the server + for resource in self._registered_resources.values(): + resource.create_fastmcp_resource(mcp_server) + + def clear_resources(self) -> None: + """Clear all registered resources""" + self._registered_resources.clear() diff --git a/src/mcp_template/server/server_factory.py b/src/mcp_template/server/server_factory.py new file mode 100644 index 0000000..39ac0b0 --- /dev/null +++ b/src/mcp_template/server/server_factory.py @@ -0,0 +1,258 @@ +""" +MCP Server Factory for easy server creation and configuration +""" +from typing import List, Optional, Union +from ..core.types import MCPServerConfig, MCPTool, MCPResource, MCPPrompt, TransportType +from .modular_server import ModularMCPServer +from ..core.interfaces import IMCPServer +from ..tools.tool_registry import ToolRegistry + + +class MCPServerFactory: + """Factory class for creating MCP servers with different configurations""" + + @staticmethod + def create_server( + name: str, + version: str = "1.0.0", + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + stateless_http: bool = True, + tools: Optional[List[MCPTool]] = None, + resources: Optional[List[MCPResource]] = None, + prompts: Optional[List[MCPPrompt]] = None, + ) -> IMCPServer: + """Create an MCP server with the specified configuration""" + + # Convert string transport to enum if needed + if isinstance(transport, str): + transport = TransportType(transport.lower()) + + config = MCPServerConfig( + name=name, + version=version, + transport=transport, + host=host, + port=port, + stateless_http=stateless_http, + tools=tools or [], + resources=resources or [], + prompts=prompts or [], + ) + + return ModularMCPServer( + name=config.name, + host=config.host, + port=config.port, + stateless_http=config.stateless_http + ) + + @staticmethod + def create_basic_calculator_server( + name: str = "CalculatorServer", + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + ) -> IMCPServer: + """Create a basic calculator server with common math tools""" + + async def add(a: float, b: float) -> float: + """Add two numbers together""" + return a + b + + async def subtract(a: float, b: float) -> float: + """Subtract b from a""" + return a - b + + async def multiply(a: float, b: float) -> float: + """Multiply two numbers""" + return a * b + + async def divide(a: float, b: float) -> float: + """Divide a by b""" + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + tools = [ + MCPTool( + name="add", + description="Add two numbers together", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=add, + ), + MCPTool( + name="subtract", + description="Subtract second number from first", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Number to subtract"}, + }, + "required": ["a", "b"], + }, + handler=subtract, + ), + MCPTool( + name="multiply", + description="Multiply two numbers", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=multiply, + ), + MCPTool( + name="divide", + description="Divide first number by second", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "Dividend"}, + "b": {"type": "number", "description": "Divisor (cannot be zero)"}, + }, + "required": ["a", "b"], + }, + handler=divide, + ), + ] + + return MCPServerFactory.create_server( + name=name, + transport=transport, + host=host, + port=port, + tools=tools, + ) + + @staticmethod + def create_knowledge_base_server( + name: str = "KnowledgeBaseServer", + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + kb_data: Optional[dict] = None, + ) -> IMCPServer: + """Create a knowledge base server with configurable data""" + + if kb_data is None: + kb_data = { + "company_policy": "Default company policy information...", + "faq": "Frequently asked questions and answers...", + } + + async def get_knowledge_base() -> str: + """Retrieve the entire knowledge base as formatted string""" + formatted = "Knowledge Base:\n\n" + for key, value in kb_data.items(): + formatted += f"**{key.replace('_', ' ').title()}:**\n{value}\n\n" + return formatted + + async def search_kb(query: str) -> str: + """Search the knowledge base for relevant information""" + query_lower = query.lower() + results = [] + + for key, value in kb_data.items(): + if query_lower in key.lower() or query_lower in value.lower(): + results.append(f"**{key.replace('_', ' ').title()}:**\n{value}") + + if not results: + return f"No information found for query: {query}" + + return "\n\n".join(results) + + tools = [ + MCPTool( + name="get_knowledge_base", + description="Retrieve the entire knowledge base as a formatted string", + input_schema={"type": "object", "properties": {}}, + handler=get_knowledge_base, + ), + MCPTool( + name="search_kb", + description="Search the knowledge base for relevant information", + input_schema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + }, + handler=search_kb, + ), + ] + + return MCPServerFactory.create_server( + name=name, + transport=transport, + host=host, + port=port, + tools=tools, + ) + + @staticmethod + def create_server_from_categories( + name: str, + categories: List[str], + transport: Union[str, TransportType] = TransportType.STDIO, + host: str = "0.0.0.0", + port: int = 8050, + custom_tools: Optional[List[MCPTool]] = None, + resources: Optional[List[MCPResource]] = None, + prompts: Optional[List[MCPPrompt]] = None, + ) -> IMCPServer: + """Create a server using tool categories""" + + # Get tools from categories + registry = ToolRegistry() + category_tools = registry.get_tools_by_categories(categories) + + # Combine with custom tools + all_tools = category_tools + if custom_tools: + all_tools.extend(custom_tools) + + return MCPServerFactory.create_server( + name=name, + transport=transport, + host=host, + port=port, + tools=all_tools, + resources=resources, + prompts=prompts, + ) + + @staticmethod + def create_modular_server( + name: str = "ModularServer", + host: str = "0.0.0.0", + port: int = 8050, + stateless_http: bool = True, + tools_directory: Optional[str] = None, + prompts_directory: Optional[str] = None, + resources_directory: Optional[str] = None, + ) -> ModularMCPServer: + """Create a modular server that auto-discovers tools, prompts, and resources""" + return ModularMCPServer( + name=name, + host=host, + port=port, + stateless_http=stateless_http, + tools_directory=tools_directory, + prompts_directory=prompts_directory, + resources_directory=resources_directory, + ) diff --git a/src/mcp_template/server/tools/__init__.py b/src/mcp_template/server/tools/__init__.py new file mode 100644 index 0000000..2480785 --- /dev/null +++ b/src/mcp_template/server/tools/__init__.py @@ -0,0 +1,7 @@ +""" +Server Tools Module +""" +from .base_tool import BaseServerTool +from .tool_registry import ServerToolRegistry + +__all__ = ['BaseServerTool', 'ServerToolRegistry'] diff --git a/src/mcp_template/server/tools/base_tool.py b/src/mcp_template/server/tools/base_tool.py new file mode 100644 index 0000000..0eb9f5f --- /dev/null +++ b/src/mcp_template/server/tools/base_tool.py @@ -0,0 +1,59 @@ +""" +Base Server Tool Class +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from mcp.server.fastmcp import Context +from mcp.server.session import ServerSession + + +class BaseServerTool(ABC): + """Base class for server tools that can be registered with FastMCP""" + + def __init__(self, name: str, description: str, input_schema: Dict[str, Any]): + self.name = name + self.description = description + self.input_schema = input_schema + + @abstractmethod + async def execute(self, **kwargs) -> Any: + """Execute the tool with the provided arguments""" + pass + + def get_tool_definition(self) -> Dict[str, Any]: + """Get the tool definition for FastMCP registration""" + return { + "name": self.name, + "description": self.description, + "input_schema": self.input_schema + } + + def create_fastmcp_tool(self, mcp_server): + """Create a FastMCP tool decorator for this tool""" + @mcp_server.tool(name=self.name, description=self.description) + async def tool_wrapper(**kwargs): + return await self.execute(**kwargs) + + return tool_wrapper + + +class ContextAwareTool(BaseServerTool): + """Base class for tools that need access to MCP context""" + + @abstractmethod + async def execute_with_context(self, ctx: Context[ServerSession, None], **kwargs) -> Any: + """Execute the tool with MCP context""" + pass + + async def execute(self, **kwargs) -> Any: + """Default implementation that doesn't use context""" + # This will be overridden by the FastMCP wrapper + raise NotImplementedError("Use execute_with_context for context-aware tools") + + def create_fastmcp_tool(self, mcp_server): + """Create a FastMCP tool decorator for this context-aware tool""" + @mcp_server.tool(name=self.name, description=self.description) + async def tool_wrapper(ctx: Context[ServerSession, None], **kwargs): + return await self.execute_with_context(ctx, **kwargs) + + return tool_wrapper diff --git a/src/mcp_template/server/tools/calculator_tool.py b/src/mcp_template/server/tools/calculator_tool.py new file mode 100644 index 0000000..05fed15 --- /dev/null +++ b/src/mcp_template/server/tools/calculator_tool.py @@ -0,0 +1,48 @@ +""" +Calculator Tool Example +""" +from .base_tool import BaseServerTool + + +class CalculatorTool(BaseServerTool): + """A simple calculator tool""" + + def __init__(self): + super().__init__( + name="calculator", + description="Perform basic mathematical operations", + input_schema={ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The mathematical operation to perform" + }, + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["operation", "a", "b"] + } + ) + + async def execute(self, operation: str, a: float, b: float) -> float: + """Execute the calculator operation""" + if operation == "add": + return a + b + elif operation == "subtract": + return a - b + elif operation == "multiply": + return a * b + elif operation == "divide": + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + else: + raise ValueError(f"Unknown operation: {operation}") diff --git a/src/mcp_template/server/tools/greeting_tool.py b/src/mcp_template/server/tools/greeting_tool.py new file mode 100644 index 0000000..3172e49 --- /dev/null +++ b/src/mcp_template/server/tools/greeting_tool.py @@ -0,0 +1,54 @@ +""" +Greeting Tool Example +""" +from .base_tool import ContextAwareTool +from mcp.server.fastmcp import Context +from mcp.server.session import ServerSession + + +class GreetingTool(ContextAwareTool): + """A greeting tool that uses MCP context""" + + def __init__(self): + super().__init__( + name="greeting", + description="Generate personalized greetings with progress updates", + input_schema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the person to greet" + }, + "style": { + "type": "string", + "enum": ["friendly", "formal", "casual"], + "description": "Style of greeting", + "default": "friendly" + } + }, + "required": ["name"] + } + ) + + async def execute_with_context(self, ctx: Context[ServerSession, None], name: str, style: str = "friendly") -> str: + """Generate a greeting with progress updates""" + await ctx.info(f"Generating {style} greeting for {name}") + + # Simulate some work with progress updates + await ctx.report_progress(progress=0.3, total=1.0, message="Preparing greeting...") + + styles = { + "friendly": f"Hello there, {name}! Great to see you!", + "formal": f"Good day, {name}. I hope you are well.", + "casual": f"Hey {name}! What's up?" + } + + await ctx.report_progress(progress=0.7, total=1.0, message="Generating message...") + + greeting = styles.get(style, styles["friendly"]) + + await ctx.report_progress(progress=1.0, total=1.0, message="Greeting complete!") + await ctx.debug(f"Generated greeting: {greeting}") + + return greeting diff --git a/src/mcp_template/server/tools/tool_registry.py b/src/mcp_template/server/tools/tool_registry.py new file mode 100644 index 0000000..eceb705 --- /dev/null +++ b/src/mcp_template/server/tools/tool_registry.py @@ -0,0 +1,198 @@ +""" +Server Tool Registry for dynamic tool registration +""" +import os +import importlib +import inspect +from typing import List, Dict, Any, Type,Optional +from pathlib import Path +from mcp_template.server.tools.base_tool import BaseServerTool + + +class ServerToolRegistry: + """Registry for managing server tools from files""" + + def __init__(self, tools_directory: str = None): + self.tools_directory = tools_directory or os.path.dirname(__file__) + self._registered_tools: Dict[str, BaseServerTool] = {} + self._tool_files: List[str] = [] + + @property + def directory(self): + """Alias for tools_directory for backward compatibility""" + return self.tools_directory + + @property + def _tools(self): + """Alias for _registered_tools for backward compatibility""" + return self._registered_tools + + def discover_tools(self) -> List[BaseServerTool]: + """Discover all tools in the tools directory""" + tools = [] + tools_dir = Path(self.tools_directory) + + if not tools_dir.exists(): + return tools + + # Find all Python files in the tools directory + for file_path in tools_dir.glob("*.py"): + if file_path.name.startswith("__"): + continue + + module_name = file_path.stem + try: + # Import the module + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # First, look for BaseServerTool subclasses (class-based approach) + for name, obj in inspect.getmembers(module, inspect.isclass): + if (issubclass(obj, BaseServerTool) and + obj != BaseServerTool and + not inspect.isabstract(obj)): + try: + tool_instance = obj() + tools.append(tool_instance) + self._registered_tools[tool_instance.name] = tool_instance + except Exception as e: + print(f"Warning: Could not instantiate tool {name}: {e}") + + # Second, look for static methods that return MCPTool objects (static approach) + for name, obj in inspect.getmembers(module): + if (inspect.isfunction(obj) and + hasattr(obj, '__name__') and + not name.startswith('_') and + name.endswith('_tool')): # Convention: methods ending with _tool + try: + # Check if it's a static method by calling it + if hasattr(obj, '__self__') and obj.__self__ is not None: + continue # Skip instance methods + + # Try to call the method to get MCPTool objects + result = obj() + if isinstance(result, list): + # Method returns a list of MCPTool objects + for tool in result: + if hasattr(tool, 'name') and hasattr(tool, 'handler'): + # Convert MCPTool to BaseServerTool wrapper + wrapper = self._create_tool_wrapper(tool) + tools.append(wrapper) + self._registered_tools[tool.name] = wrapper + elif hasattr(result, 'name') and hasattr(result, 'handler'): + # Method returns a single MCPTool object + wrapper = self._create_tool_wrapper(result) + tools.append(wrapper) + self._registered_tools[result.name] = wrapper + + except Exception as e: + # Skip methods that don't work as expected + continue + + # Third, look for classes with get_tools() static method + for name, obj in inspect.getmembers(module, inspect.isclass): + if hasattr(obj, 'get_tools') and callable(getattr(obj, 'get_tools')): + try: + # Call get_tools() to get MCPTool objects + mcp_tools = obj.get_tools() + if isinstance(mcp_tools, list): + for tool in mcp_tools: + if hasattr(tool, 'name') and hasattr(tool, 'handler'): + wrapper = self._create_tool_wrapper(tool) + tools.append(wrapper) + self._registered_tools[tool.name] = wrapper + except Exception as e: + print(f"Warning: Could not get tools from {name}: {e}") + + except Exception as e: + print(f"Warning: Could not load tool file {file_path}: {e}") + + return tools + + def _create_tool_wrapper(self, mcp_tool) -> BaseServerTool: + """Convert an MCPTool to a BaseServerTool wrapper""" + from mcp_template.server.tools.base_tool import BaseServerTool + + class MCPToolWrapper(BaseServerTool): + def __init__(self, mcp_tool): + super().__init__( + name=mcp_tool.name, + description=getattr(mcp_tool, 'description', ''), + input_schema=getattr(mcp_tool, 'input_schema', {}) + ) + self._mcp_tool = mcp_tool + + async def execute(self, **kwargs): + # Handle different argument formats that OpenAI might send + actual_kwargs = kwargs.copy() + + # Check if this is FastMCP's kwargs format + if len(kwargs) == 1 and 'kwargs' in kwargs: + kwargs_value = kwargs['kwargs'] + if isinstance(kwargs_value, str): + # Try to parse as JSON first + try: + import json + parsed = json.loads(kwargs_value) + if isinstance(parsed, dict): + actual_kwargs = parsed + except (json.JSONDecodeError, TypeError): + # If not JSON, try to map to expected parameters + input_schema = getattr(self._mcp_tool, 'input_schema', {}) + properties = input_schema.get('properties', {}) + if properties and len(properties) == 1: + param_name = list(properties.keys())[0] + actual_kwargs = {param_name: kwargs_value} + elif isinstance(kwargs_value, dict): + # kwargs_value is already a dict + actual_kwargs = kwargs_value + + # Call the MCPTool handler with the processed arguments + if hasattr(self._mcp_tool, 'handler') and callable(self._mcp_tool.handler): + try: + result = await self._mcp_tool.handler(**actual_kwargs) + return result + except TypeError as e: + print(f"TypeError in tool {self.name}: {e}") + print(f"Expected parameters: {list(getattr(self._mcp_tool, 'input_schema', {}).get('properties', {}).keys())}") + print(f"Received: {list(actual_kwargs.keys())}") + raise + return None + + return MCPToolWrapper(mcp_tool) + + def register_tool(self, tool: BaseServerTool) -> None: + """Register a single tool""" + self._registered_tools[tool.name] = tool + + def register_tools(self, tools: List[BaseServerTool]) -> None: + """Register multiple tools""" + for tool in tools: + self.register_tool(tool) + + def get_tool(self, name: str) -> Optional[BaseServerTool]: + """Get a tool by name""" + return self._registered_tools.get(name) + + def get_all_tools(self) -> List[BaseServerTool]: + """Get all registered tools""" + return list(self._registered_tools.values()) + + def get_tool_names(self) -> List[str]: + """Get all registered tool names""" + return list(self._registered_tools.keys()) + + def register_tools_with_server(self, mcp_server) -> None: + """Register all discovered tools with a FastMCP server""" + # First discover tools if not already done + if not self._registered_tools: + self.discover_tools() + + # Register each tool with the server + for tool in self._registered_tools.values(): + tool.create_fastmcp_tool(mcp_server) + + def clear_tools(self) -> None: + """Clear all registered tools""" + self._registered_tools.clear() diff --git a/src/mcp_template/tools/__init__.py b/src/mcp_template/tools/__init__.py new file mode 100644 index 0000000..533ef44 --- /dev/null +++ b/src/mcp_template/tools/__init__.py @@ -0,0 +1,8 @@ +# MCP Tools Collection +from .math_tools import MathTools +from .text_tools import TextTools +from .system_tools import SystemTools +from .web_tools import WebTools +from .tool_registry import ToolRegistry + +__all__ = ['MathTools', 'TextTools', 'SystemTools', 'WebTools', 'ToolRegistry'] diff --git a/src/mcp_template/tools/math_tools.py b/src/mcp_template/tools/math_tools.py new file mode 100644 index 0000000..d604ed2 --- /dev/null +++ b/src/mcp_template/tools/math_tools.py @@ -0,0 +1,192 @@ +""" +Mathematical Tools for MCP +""" +from typing import List +from mcp_template.core.types import MCPTool + + +class MathTools: + """Collection of mathematical tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all math tools""" + return [ + MathTools._create_add_tool(), + MathTools._create_subtract_tool(), + MathTools._create_multiply_tool(), + MathTools._create_divide_tool(), + MathTools._create_power_tool(), + MathTools._create_square_root_tool(), + MathTools._create_calculate_bmi_tool(), + ] + + @staticmethod + def _create_add_tool() -> MCPTool: + """Create addition tool""" + async def add(a: float, b: float) -> float: + """Add two numbers together""" + return a + b + + return MCPTool( + name="add", + description="Add two numbers together", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=add, + ) + + @staticmethod + def _create_subtract_tool() -> MCPTool: + """Create subtraction tool""" + async def subtract(a: float, b: float) -> float: + """Subtract second number from first""" + return a - b + + return MCPTool( + name="subtract", + description="Subtract second number from first", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Number to subtract"}, + }, + "required": ["a", "b"], + }, + handler=subtract, + ) + + @staticmethod + def _create_multiply_tool() -> MCPTool: + """Create multiplication tool""" + async def multiply(a: float, b: float) -> float: + """Multiply two numbers""" + return a * b + + return MCPTool( + name="multiply", + description="Multiply two numbers", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + handler=multiply, + ) + + @staticmethod + def _create_divide_tool() -> MCPTool: + """Create division tool""" + async def divide(a: float, b: float) -> float: + """Divide first number by second""" + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + return MCPTool( + name="divide", + description="Divide first number by second", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number", "description": "Dividend"}, + "b": {"type": "number", "description": "Divisor (cannot be zero)"}, + }, + "required": ["a", "b"], + }, + handler=divide, + ) + + @staticmethod + def _create_power_tool() -> MCPTool: + """Create power tool""" + async def power(base: float, exponent: float) -> float: + """Calculate base raised to the power of exponent""" + return base ** exponent + + return MCPTool( + name="power", + description="Calculate base raised to the power of exponent", + input_schema={ + "type": "object", + "properties": { + "base": {"type": "number", "description": "Base number"}, + "exponent": {"type": "number", "description": "Exponent"}, + }, + "required": ["base", "exponent"], + }, + handler=power, + ) + + @staticmethod + def _create_square_root_tool() -> MCPTool: + """Create square root tool""" + async def square_root(number: float) -> float: + """Calculate square root of a number""" + # Ensure number is a float + try: + number = float(number) + except (ValueError, TypeError): + raise ValueError(f"Invalid number format: {number}") + + if number < 0: + raise ValueError("Cannot calculate square root of negative number") + return number ** 0.5 + + return MCPTool( + name="square_root", + description="Calculate square root of a number", + input_schema={ + "type": "object", + "properties": { + "number": {"type": "number", "description": "Number to find square root of (must be non-negative)"}, + }, + "required": ["number"], + }, + handler=square_root, + ) + + @staticmethod + def _create_calculate_bmi_tool() -> MCPTool: + """Create BMI calculation tool""" + async def calculate_bmi(weight_kg: float, height_m: float) -> str: + """Calculate BMI and provide health category""" + if weight_kg <= 0 or height_m <= 0: + raise ValueError("Weight and height must be positive numbers") + + bmi = weight_kg / (height_m ** 2) + + if bmi < 18.5: + category = "Underweight" + elif bmi < 25: + category = "Normal weight" + elif bmi < 30: + category = "Overweight" + else: + category = "Obese" + + return ".1f" + + return MCPTool( + name="calculate_bmi", + description="Calculate BMI and provide health assessment", + input_schema={ + "type": "object", + "properties": { + "weight_kg": {"type": "number", "description": "Weight in kilograms"}, + "height_m": {"type": "number", "description": "Height in meters"}, + }, + "required": ["weight_kg", "height_m"], + }, + handler=calculate_bmi, + ) diff --git a/src/mcp_template/tools/system_tools.py b/src/mcp_template/tools/system_tools.py new file mode 100644 index 0000000..c588704 --- /dev/null +++ b/src/mcp_template/tools/system_tools.py @@ -0,0 +1,157 @@ +""" +System Tools for MCP +""" +import os +import platform +import psutil +from datetime import datetime +from typing import Dict, Any, List +from mcp_template.core.types import MCPTool + + +class SystemTools: + """Collection of system-related tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all system tools""" + return [ + SystemTools._create_get_system_info_tool(), + SystemTools._create_get_current_time_tool(), + SystemTools._create_list_directory_tool(), + SystemTools._create_get_file_info_tool(), + SystemTools._create_get_environment_variable_tool(), + ] + + @staticmethod + def _create_get_system_info_tool() -> MCPTool: + """Create system info tool""" + async def get_system_info() -> Dict[str, Any]: + """Get basic system information""" + try: + return { + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "cpu_count": os.cpu_count(), + "memory_total": psutil.virtual_memory().total if psutil else "psutil not available", + "memory_available": psutil.virtual_memory().available if psutil else "psutil not available", + } + except Exception as e: + return {"error": f"Could not retrieve system info: {str(e)}"} + + return MCPTool( + name="get_system_info", + description="Get basic system information including OS, CPU, and memory details", + input_schema={"type": "object", "properties": {}}, + handler=get_system_info, + ) + + @staticmethod + def _create_get_current_time_tool() -> MCPTool: + """Create current time tool""" + async def get_current_time() -> str: + """Get the current date and time""" + now = datetime.now() + return now.strftime("%Y-%m-%d %H:%M:%S") + + return MCPTool( + name="get_current_time", + description="Get the current date and time in YYYY-MM-DD HH:MM:SS format", + input_schema={"type": "object", "properties": {}}, + handler=get_current_time, + ) + + @staticmethod + def _create_list_directory_tool() -> MCPTool: + """Create directory listing tool""" + async def list_directory(path: str = ".") -> List[str]: + """List contents of a directory""" + try: + if not os.path.exists(path): + raise ValueError(f"Path does not exist: {path}") + + if not os.path.isdir(path): + raise ValueError(f"Path is not a directory: {path}") + + return os.listdir(path) + except Exception as e: + raise ValueError(f"Could not list directory: {str(e)}") + + return MCPTool( + name="list_directory", + description="List the contents of a directory", + input_schema={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path to list", + "default": "." + }, + }, + }, + handler=list_directory, + ) + + @staticmethod + def _create_get_file_info_tool() -> MCPTool: + """Create file info tool""" + async def get_file_info(file_path: str) -> Dict[str, Any]: + """Get information about a file""" + try: + if not os.path.exists(file_path): + raise ValueError(f"File does not exist: {file_path}") + + stat = os.stat(file_path) + return { + "name": os.path.basename(file_path), + "path": os.path.abspath(file_path), + "size": stat.st_size, + "is_file": os.path.isfile(file_path), + "is_directory": os.path.isdir(file_path), + "modified_time": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "created_time": datetime.fromtimestamp(stat.st_ctime).isoformat(), + } + except Exception as e: + raise ValueError(f"Could not get file info: {str(e)}") + + return MCPTool( + name="get_file_info", + description="Get detailed information about a file or directory", + input_schema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the file or directory"}, + }, + "required": ["file_path"], + }, + handler=get_file_info, + ) + + @staticmethod + def _create_get_environment_variable_tool() -> MCPTool: + """Create environment variable tool""" + async def get_environment_variable(name: str, default_value: str = "") -> str: + """Get the value of an environment variable""" + return os.getenv(name, default_value) + + return MCPTool( + name="get_environment_variable", + description="Get the value of an environment variable", + input_schema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Name of the environment variable"}, + "default_value": { + "type": "string", + "description": "Default value if variable is not set", + "default": "" + }, + }, + "required": ["name"], + }, + handler=get_environment_variable, + ) diff --git a/src/mcp_template/tools/text_tools.py b/src/mcp_template/tools/text_tools.py new file mode 100644 index 0000000..6f85ff5 --- /dev/null +++ b/src/mcp_template/tools/text_tools.py @@ -0,0 +1,187 @@ +""" +Text Processing Tools for MCP +""" +import re +from typing import List +from mcp_template.core.types import MCPTool + + +class TextTools: + """Collection of text processing tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all text tools""" + return [ + TextTools._create_word_count_tool(), + TextTools._create_text_search_tool(), + TextTools._create_text_replace_tool(), + TextTools._create_text_uppercase_tool(), + TextTools._create_text_lowercase_tool(), + TextTools._create_text_length_tool(), + TextTools._create_generate_greeting_tool(), + ] + + @staticmethod + def _create_word_count_tool() -> MCPTool: + """Create word count tool""" + async def count_words(text: str) -> int: + """Count the number of words in a text""" + words = text.strip().split() + return len(words) + + return MCPTool( + name="count_words", + description="Count the number of words in a text", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to count words in"}, + }, + "required": ["text"], + }, + handler=count_words, + ) + + @staticmethod + def _create_text_search_tool() -> MCPTool: + """Create text search tool""" + async def search_text(text: str, pattern: str, case_sensitive: bool = False) -> List[str]: + """Search for a pattern in text and return all matches""" + flags = 0 if case_sensitive else re.IGNORECASE + matches = re.findall(pattern, text, flags) + return matches + + return MCPTool( + name="search_text", + description="Search for a pattern in text and return all matches", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to search in"}, + "pattern": {"type": "string", "description": "Regular expression pattern to search for"}, + "case_sensitive": {"type": "boolean", "description": "Whether search should be case sensitive", "default": False}, + }, + "required": ["text", "pattern"], + }, + handler=search_text, + ) + + @staticmethod + def _create_text_replace_tool() -> MCPTool: + """Create text replace tool""" + async def replace_text(text: str, old_pattern: str, new_text: str) -> str: + """Replace all occurrences of a pattern in text""" + return text.replace(old_pattern, new_text) + + return MCPTool( + name="replace_text", + description="Replace all occurrences of a pattern in text", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Original text"}, + "old_pattern": {"type": "string", "description": "Text to replace"}, + "new_text": {"type": "string", "description": "Replacement text"}, + }, + "required": ["text", "old_pattern", "new_text"], + }, + handler=replace_text, + ) + + @staticmethod + def _create_text_uppercase_tool() -> MCPTool: + """Create uppercase tool""" + async def to_uppercase(text: str) -> str: + """Convert text to uppercase""" + return text.upper() + + return MCPTool( + name="to_uppercase", + description="Convert text to uppercase", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to convert to uppercase"}, + }, + "required": ["text"], + }, + handler=to_uppercase, + ) + + @staticmethod + def _create_text_lowercase_tool() -> MCPTool: + """Create lowercase tool""" + async def to_lowercase(text: str) -> str: + """Convert text to lowercase""" + return text.lower() + + return MCPTool( + name="to_lowercase", + description="Convert text to lowercase", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to convert to lowercase"}, + }, + "required": ["text"], + }, + handler=to_lowercase, + ) + + @staticmethod + def _create_text_length_tool() -> MCPTool: + """Create text length tool""" + async def text_length(text: str) -> int: + """Get the length of text""" + return len(text) + + return MCPTool( + name="text_length", + description="Get the length of text (number of characters)", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to measure length of"}, + }, + "required": ["text"], + }, + handler=text_length, + ) + + @staticmethod + def _create_generate_greeting_tool() -> MCPTool: + """Create greeting generation tool""" + async def generate_greeting(name: str, style: str = "casual") -> str: + """Generate a personalized greeting""" + name = name.strip() + if not name: + raise ValueError("Name cannot be empty") + + if style == "casual": + return f"Hey {name}! Welcome! 👋" + elif style == "formal": + return f"Good day, {name}. Welcome to our platform." + elif style == "professional": + return f"Hello, {name}. Thank you for joining us." + else: + return f"Hi {name}! Welcome!" + + return MCPTool( + name="generate_greeting", + description="Generate a personalized greeting with different styles", + input_schema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Person's name"}, + "style": { + "type": "string", + "enum": ["casual", "formal", "professional"], + "description": "Greeting style", + "default": "casual" + }, + }, + "required": ["name"], + }, + handler=generate_greeting, + ) diff --git a/src/mcp_template/tools/tool_registry.py b/src/mcp_template/tools/tool_registry.py new file mode 100644 index 0000000..898d598 --- /dev/null +++ b/src/mcp_template/tools/tool_registry.py @@ -0,0 +1,80 @@ +""" +Tool Registry for easy tool management and combination +""" +from typing import List, Dict, Any, Optional +from mcp_template.core.types import MCPTool +from mcp_template.tools.math_tools import MathTools +from mcp_template.tools.text_tools import TextTools +from mcp_template.tools.system_tools import SystemTools +from mcp_template.tools.web_tools import WebTools + + +class ToolRegistry: + """Registry for managing and combining MCP tools from different categories""" + + def __init__(self): + self._tool_categories = { + 'math': MathTools, + 'text': TextTools, + 'system': SystemTools, + 'web': WebTools, + } + self._custom_tools: List[MCPTool] = [] + + def get_tools_by_category(self, category: str) -> List[MCPTool]: + """Get all tools from a specific category""" + if category not in self._tool_categories: + raise ValueError(f"Unknown tool category: {category}") + + return self._tool_categories[category].get_tools() + + def get_tools_by_categories(self, categories: List[str]) -> List[MCPTool]: + """Get tools from multiple categories""" + tools = [] + for category in categories: + tools.extend(self.get_tools_by_category(category)) + return tools + + def get_all_tools(self) -> List[MCPTool]: + """Get all tools from all categories""" + tools = [] + for category in self._tool_categories.values(): + tools.extend(category.get_tools()) + tools.extend(self._custom_tools) + return tools + + def add_custom_tool(self, tool: MCPTool) -> None: + """Add a custom tool to the registry""" + self._custom_tools.append(tool) + + def add_custom_tools(self, tools: List[MCPTool]) -> None: + """Add multiple custom tools to the registry""" + self._custom_tools.extend(tools) + + def get_available_categories(self) -> List[str]: + """Get list of available tool categories""" + return list(self._tool_categories.keys()) + + def get_category_info(self) -> Dict[str, Dict[str, Any]]: + """Get information about each category""" + info = {} + for category_name, category_class in self._tool_categories.items(): + tools = category_class.get_tools() + info[category_name] = { + 'tool_count': len(tools), + 'tool_names': [tool.name for tool in tools] + } + return info + + def create_server_config(self, categories: Optional[List[str]] = None) -> Dict[str, Any]: + """Create a server configuration with tools from specified categories""" + if categories is None: + tools = self.get_all_tools() + else: + tools = self.get_tools_by_categories(categories) + + return { + 'tools': tools, + 'tool_count': len(tools), + 'categories': categories or self.get_available_categories() + } diff --git a/src/mcp_template/tools/web_tools.py b/src/mcp_template/tools/web_tools.py new file mode 100644 index 0000000..246d6f5 --- /dev/null +++ b/src/mcp_template/tools/web_tools.py @@ -0,0 +1,161 @@ +""" +Web Tools for MCP +""" +import urllib.parse +from typing import Dict, Any, List, Optional +from mcp_template.core.types import MCPTool + + +class WebTools: + """Collection of web-related tools""" + + @staticmethod + def get_tools() -> List[MCPTool]: + """Get all web tools""" + return [ + WebTools._create_url_encode_tool(), + WebTools._create_url_decode_tool(), + WebTools._create_parse_url_tool(), + WebTools._create_validate_email_tool(), + WebTools._create_extract_domain_tool(), + ] + + @staticmethod + def _create_url_encode_tool() -> MCPTool: + """Create URL encode tool""" + async def url_encode(text: str) -> str: + """URL encode a string""" + return urllib.parse.quote(text) + + return MCPTool( + name="url_encode", + description="URL encode a string for safe transmission", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to URL encode"}, + }, + "required": ["text"], + }, + handler=url_encode, + ) + + @staticmethod + def _create_url_decode_tool() -> MCPTool: + """Create URL decode tool""" + async def url_decode(encoded_text: str) -> str: + """URL decode a string""" + try: + return urllib.parse.unquote(encoded_text) + except Exception: + raise ValueError("Invalid URL encoded string") + + return MCPTool( + name="url_decode", + description="URL decode a previously encoded string", + input_schema={ + "type": "object", + "properties": { + "encoded_text": {"type": "string", "description": "URL encoded text to decode"}, + }, + "required": ["encoded_text"], + }, + handler=url_decode, + ) + + @staticmethod + def _create_parse_url_tool() -> MCPTool: + """Create URL parsing tool""" + async def parse_url(url: str) -> Dict[str, str]: + """Parse a URL and return its components""" + try: + parsed = urllib.parse.urlparse(url) + return { + "scheme": parsed.scheme, + "netloc": parsed.netloc, + "hostname": parsed.hostname, + "port": str(parsed.port) if parsed.port else "", + "path": parsed.path, + "query": parsed.query, + "fragment": parsed.fragment, + } + except Exception: + raise ValueError("Invalid URL format") + + return MCPTool( + name="parse_url", + description="Parse a URL and return its components (scheme, host, path, etc.)", + input_schema={ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to parse"}, + }, + "required": ["url"], + }, + handler=parse_url, + ) + + @staticmethod + def _create_validate_email_tool() -> MCPTool: + """Create email validation tool""" + async def validate_email(email: str) -> Dict[str, Any]: + """Validate an email address format""" + import re + + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + is_valid = bool(re.match(email_pattern, email)) + + # Extract domain + if is_valid and '@' in email: + domain = email.split('@')[1] + else: + domain = "" + + return { + "email": email, + "is_valid": is_valid, + "domain": domain, + } + + return MCPTool( + name="validate_email", + description="Validate an email address format and extract domain", + input_schema={ + "type": "object", + "properties": { + "email": {"type": "string", "description": "Email address to validate"}, + }, + "required": ["email"], + }, + handler=validate_email, + ) + + @staticmethod + def _create_extract_domain_tool() -> MCPTool: + """Create domain extraction tool""" + async def extract_domain(url: str) -> str: + """Extract the domain from a URL""" + try: + parsed = urllib.parse.urlparse(url) + if parsed.hostname: + return parsed.hostname + else: + # Fallback for URLs without scheme + url_with_scheme = "http://" + url if "://" not in url else url + parsed = urllib.parse.urlparse(url_with_scheme) + return parsed.hostname or "" + except Exception: + return "" + + return MCPTool( + name="extract_domain", + description="Extract the domain name from a URL", + input_schema={ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to extract domain from"}, + }, + "required": ["url"], + }, + handler=extract_domain, + ) diff --git a/src/mcp_template/transport/__init__.py b/src/mcp_template/transport/__init__.py new file mode 100644 index 0000000..f9885a7 --- /dev/null +++ b/src/mcp_template/transport/__init__.py @@ -0,0 +1,6 @@ +# Transport layer implementations +from .transport_manager import TransportManager +from .sse_transport import SSETransport +from .stdio_transport import STDIOTransport + +__all__ = ['TransportManager', 'SSETransport', 'STDIOTransport'] diff --git a/src/mcp_template/transport/sse_transport.py b/src/mcp_template/transport/sse_transport.py new file mode 100644 index 0000000..daf0d21 --- /dev/null +++ b/src/mcp_template/transport/sse_transport.py @@ -0,0 +1,101 @@ +""" +SSE (Server-Sent Events) Transport Implementation +""" +import asyncio +import json +from typing import Dict, Any, Optional +from contextlib import asynccontextmanager + +try: + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + +from ..core.interfaces import IMCPTransport + + +class SSETransport(IMCPTransport): + """SSE transport for MCP communication""" + + def __init__( + self, + host: str = "localhost", + port: int = 8050, + endpoint: str = "/sse", + **kwargs + ): + if not AIOHTTP_AVAILABLE: + raise ImportError("aiohttp package not installed. Install with: pip install aiohttp") + + self.host = host + self.port = port + self.endpoint = endpoint + self._session: Optional[aiohttp.ClientSession] = None + self._response: Optional[aiohttp.ClientResponse] = None + self._connected = False + + @asynccontextmanager + async def connect(self): + """Establish SSE connection""" + try: + self._session = aiohttp.ClientSession() + url = f"http://{self.host}:{self.port}{self.endpoint}" + + print(f"Connecting to SSE endpoint: {url}") + self._response = await self._session.get(url) + + if self._response.status != 200: + raise ConnectionError(f"SSE connection failed with status {self._response.status}") + + self._connected = True + yield self + + except Exception as e: + print(f"SSE connection error: {e}") + raise + finally: + await self.close() + + async def send_message(self, message: Dict[str, Any]) -> None: + """Send a message through SSE transport""" + if not self._connected: + raise ConnectionError("SSE transport not connected") + + # SSE is typically unidirectional from server to client + # For bidirectional communication, you might need to use a different approach + # or combine SSE with HTTP POST requests + print(f"SSE Transport: Sending message: {message}") + + # TODO: Implement actual message sending logic + # This might require HTTP POST to a separate endpoint + # or WebSocket upgrade, depending on server implementation + + async def receive_message(self) -> Dict[str, Any]: + """Receive a message through SSE transport""" + if not self._connected or not self._response: + raise ConnectionError("SSE transport not connected") + + # Read SSE data + async for line in self._response.content: + line_str = line.decode('utf-8').strip() + + if line_str.startswith('data: '): + data = line_str[6:] # Remove 'data: ' prefix + if data: + try: + return json.loads(data) + except json.JSONDecodeError: + continue + + return {} + + async def close(self) -> None: + """Close the SSE connection""" + if self._response: + self._response.close() + if self._session: + await self._session.close() + + self._connected = False + print("SSE transport closed") diff --git a/src/mcp_template/transport/stdio_transport.py b/src/mcp_template/transport/stdio_transport.py new file mode 100644 index 0000000..d480f6b --- /dev/null +++ b/src/mcp_template/transport/stdio_transport.py @@ -0,0 +1,81 @@ +""" +STDIO Transport Implementation +""" +import asyncio +import json +import sys +from typing import Dict, Any, Optional +from contextlib import asynccontextmanager + +from ..core.interfaces import IMCPTransport + + +class STDIOTransport(IMCPTransport): + """STDIO transport for MCP communication""" + + def __init__(self, **kwargs): + self._connected = False + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + + @asynccontextmanager + async def connect(self): + """Establish STDIO connection""" + try: + # Use stdin/stdout for communication + self._reader = asyncio.StreamReader() + reader_protocol = asyncio.StreamReaderProtocol(self._reader) + await asyncio.get_event_loop().connect_read_pipe( + lambda: reader_protocol, sys.stdin + ) + + # For writing, we'll use stdout + self._writer = None # We'll write directly to stdout + + self._connected = True + print("STDIO transport connected", file=sys.stderr) + yield self + + except Exception as e: + print(f"STDIO connection error: {e}", file=sys.stderr) + raise + finally: + await self.close() + + async def send_message(self, message: Dict[str, Any]) -> None: + """Send a message through STDIO""" + if not self._connected: + raise ConnectionError("STDIO transport not connected") + + # Convert message to JSON and send to stdout + message_json = json.dumps(message, separators=(',', ':')) + print(message_json, flush=True) + + async def receive_message(self) -> Dict[str, Any]: + """Receive a message through STDIO""" + if not self._connected or not self._reader: + raise ConnectionError("STDIO transport not connected") + + try: + # Read line from stdin + line = await self._reader.readline() + if not line: + return {} # EOF + + line_str = line.decode('utf-8').strip() + if line_str: + return json.loads(line_str) + + except json.JSONDecodeError as e: + print(f"Invalid JSON received: {e}", file=sys.stderr) + except Exception as e: + print(f"Error receiving message: {e}", file=sys.stderr) + + return {} + + async def close(self) -> None: + """Close the STDIO connection""" + self._connected = False + if self._writer: + self._writer.close() + print("STDIO transport closed", file=sys.stderr) diff --git a/src/mcp_template/transport/transport_manager.py b/src/mcp_template/transport/transport_manager.py new file mode 100644 index 0000000..ff5f051 --- /dev/null +++ b/src/mcp_template/transport/transport_manager.py @@ -0,0 +1,81 @@ +""" +Transport Manager for easy switching between transport methods +""" +from typing import Optional, Union +from contextlib import asynccontextmanager + +from ..core.types import TransportType +from ..core.interfaces import IMCPTransport +from .sse_transport import SSETransport +from .stdio_transport import STDIOTransport + + +class TransportManager: + """Manager class for handling different MCP transport methods""" + + def __init__(self): + self._current_transport: Optional[IMCPTransport] = None + + @asynccontextmanager + async def get_transport( + self, + transport_type: Union[str, TransportType], + **kwargs + ): + """Get a transport instance for the specified type""" + + # Convert string to enum if needed + if isinstance(transport_type, str): + transport_type = TransportType(transport_type.lower()) + + # Create the appropriate transport + if transport_type == TransportType.SSE: + transport = SSETransport(**kwargs) + elif transport_type == TransportType.STDIO: + transport = STDIOTransport(**kwargs) + else: + raise ValueError(f"Unsupported transport type: {transport_type}") + + self._current_transport = transport + + try: + async with transport.connect(): + yield transport + finally: + self._current_transport = None + + async def switch_transport( + self, + new_transport_type: Union[str, TransportType], + **kwargs + ) -> IMCPTransport: + """Switch to a different transport type""" + + # Close current transport if exists + if self._current_transport: + await self._current_transport.close() + + # Convert string to enum if needed + if isinstance(new_transport_type, str): + new_transport_type = TransportType(new_transport_type.lower()) + + # Create new transport + if new_transport_type == TransportType.SSE: + self._current_transport = SSETransport(**kwargs) + elif new_transport_type == TransportType.STDIO: + self._current_transport = STDIOTransport(**kwargs) + else: + raise ValueError(f"Unsupported transport type: {new_transport_type}") + + return self._current_transport + + @property + def current_transport(self) -> Optional[IMCPTransport]: + """Get the currently active transport""" + return self._current_transport + + async def close_current_transport(self) -> None: + """Close the currently active transport""" + if self._current_transport: + await self._current_transport.close() + self._current_transport = None diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9a14bcb --- /dev/null +++ b/tests/README.md @@ -0,0 +1,198 @@ +# Test Suite + +This directory contains comprehensive tests for the MCP Template system. + +## Test Structure + +``` +tests/ +├── unit/ # Unit tests (fast, no external dependencies) +├── integration/ # Integration tests (require real server connections) +├── e2e/ # End-to-end tests +└── README.md # This file +``` + +## Test Types + +### Unit Tests +- **Fast**: Run in milliseconds +- **Isolated**: No external dependencies +- **Focused**: Test individual components +- **Run with**: `python run_tests.py unit` + +### Integration Tests +- **Real connections**: Test with actual MCP server +- **API calls**: May require API keys +- **Slower**: Take longer to execute +- **Run with**: `python run_tests.py integration` + +## Running Tests + +### Quick Start + +```bash +# Run all tests +python run_tests.py all + +# Run unit tests only +python run_tests.py unit + +# Run integration tests only +python run_tests.py integration + +# Run specific test file +python run_tests.py specific tests/integration/test_mcp_integration.py +``` + +### Advanced Options + +```bash +# Run with coverage report +python run_tests.py all --coverage + +# Skip slow tests +python run_tests.py integration --skip-slow + +# Run specific test markers +pytest -m "integration and not slow" +pytest -m "unit" +pytest -m "requires_api_key" +``` + +## Test Markers + +- **`@pytest.mark.unit`**: Unit tests (fast, isolated) +- **`@pytest.mark.integration`**: Integration tests (require server) +- **`@pytest.mark.requires_api_key`**: Tests requiring API keys +- **`@pytest.mark.slow`**: Slow-running tests +- **`@pytest.mark.asyncio`**: Async tests + +## Environment Setup + +### API Keys (for integration tests) + +Create a `.env` file in the project root: + +```bash +# OpenAI (required for most tests) +OPENAI_API_KEY=your_openai_api_key_here + +# Optional: Other providers +ANTHROPIC_API_KEY=your_anthropic_key_here +GROK_API_KEY=your_grok_key_here +``` + +### Test Configuration + +Tests are configured via `pytest.ini`: +- Async mode enabled +- Custom markers defined +- Test discovery patterns +- Warning filters + +## Integration Test Details + +### MCP Server Management +Integration tests automatically: +1. Start MCP server processes +2. Wait for server readiness +3. Connect test clients +4. Clean up processes after tests + +### Transport Testing +- **SSE Transport**: HTTP-based, tested with real server +- **STDIO Transport**: Direct process communication + +### AI Provider Testing +- **OpenAI**: Primary provider for most tests +- **Claude**: Tested if ANTHROPIC_API_KEY available +- **Grok**: Tested if GROK_API_KEY available + +## Writing Tests + +### Unit Test Example +```python +import pytest +from mcp_template.core.types import TransportType + +@pytest.mark.unit +def test_transport_enum(): + """Test TransportType enum values""" + assert TransportType.SSE.value == "sse" + assert TransportType.STDIO.value == "stdio" +``` + +### Integration Test Example +```python +import pytest +from mcp_llm_client import MCPAIClient, TransportType + +@pytest.mark.integration +@pytest.mark.requires_api_key +@pytest.mark.asyncio +async def test_client_connection(sse_server): + """Test MCP client can connect to server""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient(model="gpt-4o", transport=TransportType.SSE) + + try: + await client.connect(sse_server.url) + tools = await client.get_mcp_tools() + assert len(tools) > 0 + finally: + await client.disconnect() +``` + +## Test Coverage + +Run tests with coverage: + +```bash +# Generate HTML coverage report +python run_tests.py all --coverage + +# View coverage report +open htmlcov/index.html +``` + +## Troubleshooting + +### Common Issues + +1. **Server won't start**: Check port availability +2. **API key errors**: Ensure `.env` file exists +3. **Connection timeouts**: Increase timeout values +4. **Process cleanup**: Tests handle cleanup automatically + +### Debug Mode + +```bash +# Run with verbose output +pytest -v -s tests/integration/test_mcp_integration.py::TestMCPClientIntegration::test_sse_transport_connection + +# Run single test with debugging +pytest -v --pdb tests/integration/test_mcp_integration.py -k "test_sse_transport" +``` + +## CI/CD Integration + +Tests are designed to work in CI/CD environments: +- Automatic skipping when API keys unavailable +- Process cleanup on test failure +- No manual server startup required +- Cross-platform compatibility + +## Performance Testing + +Some tests measure performance: +- Connection establishment time +- Query response times +- Concurrent connection handling +- Resource cleanup efficiency + +Run performance tests: +```bash +pytest -m "slow" -v +``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e003090 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Test suite for MCP Template +# Run tests with: python -m pytest tests/ diff --git a/tests/e2e/test_end_to_end.py b/tests/e2e/test_end_to_end.py new file mode 100644 index 0000000..d6c4ff5 --- /dev/null +++ b/tests/e2e/test_end_to_end.py @@ -0,0 +1,255 @@ +""" +End-to-end tests for complete MCP workflow +""" +import pytest +import asyncio +import tempfile +import os +from pathlib import Path +from unittest.mock import Mock, AsyncMock, patch + +from mcp_template.core.types import MCPTool, MCPServerConfig, TransportType +from mcp_template.server.fastmcp_server import FastMCPServer +from mcp_template.llm_client.openai_client import OpenAIClient +from mcp_template.config.config_manager import ConfigManager + + +class TestEndToEnd: + """End-to-end tests for complete MCP workflow""" + + @pytest.fixture + async def knowledge_base_server(self): + """Create a knowledge base server for e2e testing""" + kb_data = { + "company_policy": "Our company offers 20 days of paid vacation annually.", + "benefits": "We provide health insurance, 401k matching, and flexible work hours.", + "faq": "Common questions about our policies and procedures." + } + + async def get_kb(): + formatted = "Knowledge Base:\n\n" + for key, value in kb_data.items(): + formatted += f"**{key.replace('_', ' ').title()}:**\n{value}\n\n" + return formatted + + async def search_kb(query: str): + query_lower = query.lower() + results = [] + + for key, value in kb_data.items(): + if query_lower in key.lower() or query_lower in value.lower(): + results.append(f"**{key.replace('_', ' ').title()}:**\n{value}") + + if not results: + return f"No information found for: {query}" + + return "\n\n".join(results) + + tools = [ + MCPTool( + name="get_knowledge_base", + description="Retrieve the entire knowledge base", + input_schema={"type": "object", "properties": {}}, + handler=get_kb + ), + MCPTool( + name="search_kb", + description="Search the knowledge base for specific information", + input_schema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"] + }, + handler=search_kb + ) + ] + + config = MCPServerConfig( + name="Knowledge Base Server", + tools=tools + ) + + server = FastMCPServer(config) + await server.initialize() + return server + + @pytest.fixture + def mock_openai_response(self): + """Mock OpenAI API response with tool calls""" + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": "I'll search the knowledge base for vacation policy information.", + "tool_calls": [ + { + "id": "call_123", + "function": { + "name": "search_kb", + "arguments": '{"query": "vacation"}' + } + } + ] + } + } + ] + } + + @patch('src.clients.openai_client.AsyncOpenAI') + @pytest.mark.asyncio + async def test_complete_mcp_workflow(self, mock_openai_class, knowledge_base_server, mock_openai_response): + """Test complete MCP workflow from query to response""" + + # Mock OpenAI client setup + mock_client = Mock() + mock_response = Mock() + mock_response.choices = [ + Mock(message=Mock( + role="assistant", + content="I'll search the knowledge base for vacation policy information.", + tool_calls=[ + Mock( + id="call_123", + function=Mock( + name="search_kb", + arguments='{"query": "vacation"}' + ) + ) + ] + )) + ] + + # Second call response (after tool execution) + mock_final_response = Mock() + mock_final_response.choices = [ + Mock(message=Mock( + content="Based on the knowledge base, our company offers 20 days of paid vacation annually." + )) + ] + + mock_client.chat.completions.create = AsyncMock(side_effect=[mock_response, mock_final_response]) + mock_openai_class.return_value = mock_client + + # Create AI client + ai_client = OpenAIClient("gpt-4o", "test-key") + await ai_client.initialize() + + # Create mock MCP client that uses the real server + class MockMCPClient: + def __init__(self, server): + self.server = server + + async def call_tool(self, name, arguments): + return await self.server.call_tool(name, arguments) + + async def list_tools(self): + return await self.server.list_tools() + + mcp_client = MockMCPClient(knowledge_base_server) + + # Test the complete workflow + query = "What's the company vacation policy?" + response = await ai_client.process_with_tools(query, [], mcp_client) + + # Verify the workflow + assert "vacation" in response.lower() or "20 days" in response + + # Verify OpenAI was called twice (initial + after tool execution) + assert mock_client.chat.completions.create.call_count == 2 + + # Verify the tool was called correctly + tool_result = await mcp_client.call_tool("search_kb", {"query": "vacation"}) + assert "vacation" in tool_result.lower() + + @pytest.mark.asyncio + async def test_configuration_workflow(self, knowledge_base_server): + """Test configuration loading and usage""" + + # Create temporary config file + config_data = { + "server": { + "name": "Test Server", + "port": 8080, + "transport": "sse" + }, + "client": { + "provider": "openai", + "model": "gpt-4o" + }, + "api_keys": { + "openai": "test-key" + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + import json + json.dump(config_data, f) + config_file = f.name + + try: + # Test config manager + config_manager = ConfigManager(config_file) + + server_config = await config_manager.get_server_config() + assert server_config["name"] == "Test Server" + assert server_config["port"] == 8080 + + client_config = await config_manager.get_client_config() + assert client_config["provider"] == "openai" + assert client_config["model"] == "gpt-4o" + + api_key = await config_manager.get_api_key("openai") + assert api_key == "test-key" + + finally: + # Clean up + os.unlink(config_file) + + @pytest.mark.asyncio + async def test_error_recovery(self, knowledge_base_server): + """Test error recovery in MCP operations""" + + # Test with invalid tool call + try: + await knowledge_base_server.call_tool("nonexistent_tool", {}) + assert False, "Should have raised ValueError" + except ValueError as e: + assert "not found" in str(e).lower() + + # Test with valid tool but invalid arguments + try: + await knowledge_base_server.call_tool("search_kb", {"invalid": "args"}) + assert False, "Should have raised error" + except Exception: + # Should handle gracefully + pass + + # Verify server still works after errors + result = await knowledge_base_server.call_tool("get_knowledge_base", {}) + assert "Knowledge Base" in result + + @pytest.mark.asyncio + async def test_multiple_queries_workflow(self, knowledge_base_server): + """Test multiple queries in sequence""" + + queries = [ + "vacation policy", + "benefits", + "faq" + ] + + for query in queries: + # Direct tool call (simulating what AI would do) + result = await knowledge_base_server.call_tool("search_kb", {"query": query}) + + # Verify we get relevant results + assert query.lower() in result.lower() or len(result) > 50 + + # Test getting full knowledge base + full_kb = await knowledge_base_server.call_tool("get_knowledge_base", {}) + assert "company_policy" in full_kb + assert "benefits" in full_kb + assert "faq" in full_kb diff --git a/tests/integration/test_mcp_integration.py b/tests/integration/test_mcp_integration.py new file mode 100644 index 0000000..abc5a90 --- /dev/null +++ b/tests/integration/test_mcp_integration.py @@ -0,0 +1,611 @@ +""" +Comprehensive Integration Tests for MCP Client + +This module contains end-to-end integration tests for the MCP client, +testing real server connections, tool calling, and AI provider integration. +""" +import pytest +import asyncio +import subprocess +import time +import signal +import os +import sys +from typing import Optional, Dict, Any, List +from unittest.mock import Mock, patch +import httpx + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src')) + +from mcp_llm_client import MCPAIClient, TransportType +from mcp_template.llm_client.client_factory import AIClientFactory + + +class MCPServerProcess: + """Helper class to manage MCP server process for testing""" + + def __init__(self, transport: str = "sse", port: int = 8051): + self.transport = transport + self.port = port + self.process: Optional[subprocess.Popen] = None + self.url = f"http://localhost:{port}" + + async def start(self): + """Start the MCP server process""" + cmd = [ + sys.executable, + "run_mcp_server.py", + "--transport", self.transport, + "--port", str(self.port) + ] + + self.process = subprocess.Popen( + cmd, + cwd=os.path.join(os.path.dirname(__file__), '..', '..'), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # Wait for server to be ready + await self._wait_for_server() + return self + + async def _wait_for_server(self, timeout: int = 30): + """Wait for server to be ready""" + start_time = time.time() + + while time.time() - start_time < timeout: + try: + async with httpx.AsyncClient() as client: + if self.transport == "sse": + response = await client.get(f"{self.url}/sse", timeout=5.0) + if response.status_code == 200: + return + else: + # For stdio, just wait a bit + await asyncio.sleep(2) + return + except Exception: + pass + + await asyncio.sleep(1) + + raise TimeoutError(f"Server did not start within {timeout} seconds") + + async def stop(self): + """Stop the MCP server process""" + if self.process: + try: + self.process.terminate() + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + + +@pytest.fixture +async def sse_server(): + """Fixture to start and stop SSE MCP server""" + server = MCPServerProcess(transport="sse", port=8051) + await server.start() + yield server + await server.stop() + + + @pytest.fixture +async def stdio_server(): + """Fixture to start and stop STDIO MCP server""" + server = MCPServerProcess(transport="stdio", port=8052) + await server.start() + yield server + await server.stop() + + +class TestMCPClientIntegration: + """Comprehensive integration tests for MCP client""" + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_sse_transport_connection(self, sse_server): + """Test MCP client can connect to server using SSE transport""" + # Skip if no OpenAI API key + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1, + max_tokens=100 + ) + + try: + # Test connection + await client.connect(sse_server.url) + + # Test getting tools + tools = await client.get_mcp_tools() + assert isinstance(tools, list) + assert len(tools) > 0 + + # Verify tool structure + for tool in tools: + assert "name" in tool + assert "description" in tool + assert "inputSchema" in tool + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_stdio_transport_connection(self): + """Test MCP client can connect using STDIO transport""" + # Skip if no OpenAI API key + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.STDIO, + provider="openai", + temperature=0.1, + max_tokens=100 + ) + + try: + # For STDIO, we need to provide server command + await client.connect_stdio( + server_command=[sys.executable, "run_mcp_server.py", "--transport", "stdio"] + ) + + # Test getting tools + tools = await client.get_mcp_tools() + assert isinstance(tools, list) + assert len(tools) > 0 + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.slow + @pytest.mark.asyncio + async def test_end_to_end_tool_calling_sse(self, sse_server): + """Test complete end-to-end tool calling with SSE transport""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1, + max_tokens=200 + ) + + try: + await client.connect(sse_server.url) + + # Test a simple mathematical query that should trigger tool calls + query = "Calculate 15 + 27 and then multiply the result by 2" + response = await client.process_query(query) + + assert isinstance(response, str) + assert len(response) > 0 + + # The response should contain the calculation result + # We can't predict exact wording but should contain numbers + assert any(char.isdigit() for char in response) + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_multiple_provider_support(self, sse_server): + """Test MCP client works with different AI providers""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + providers_to_test = ["openai"] + + for provider in providers_to_test: + client = MCPAIClient( + model="gpt-4o" if provider == "openai" else "claude-3-opus-20240229", + transport=TransportType.SSE, + provider=provider, + temperature=0.1, + max_tokens=100 + ) + + try: + await client.connect(sse_server.url) + + # Test basic tool listing + tools = await client.get_mcp_tools() + assert len(tools) > 0 + + # Test simple query + response = await client.process_query("What tools are available?") + assert isinstance(response, str) + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_error_handling_connection_failure(self): + """Test error handling when server connection fails""" + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai" + ) + + # Try to connect to non-existent server + with pytest.raises(Exception): + await client.connect("http://localhost:9999") + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_error_handling_invalid_query(self, sse_server): + """Test error handling with invalid queries""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1 + ) + + try: + await client.connect(sse_server.url) + + # Test with empty query + response = await client.process_query("") + assert isinstance(response, str) + + # Test with very long query + long_query = "test " * 1000 + response = await client.process_query(long_query) + assert isinstance(response, str) + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_interactive_session_mode(self, sse_server): + """Test interactive session functionality""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1, + max_tokens=100 + ) + + try: + await client.connect(sse_server.url) + + # Mock user inputs for interactive session + inputs = ["Calculate 5 + 3", "quit"] + + with patch('builtins.input', side_effect=inputs): + # This would normally run an interactive loop + # For testing, we'll just verify the client is ready + assert client.session is not None + assert client.ai_client is not None + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_tool_schema_validation(self, sse_server): + """Test that tool schemas are properly formatted for AI providers""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai" + ) + + try: + await client.connect(sse_server.url) + tools = await client.get_mcp_tools() + + # Verify OpenAI-specific tool formatting + for tool in tools: + assert "type" in tool + assert tool["type"] == "function" + assert "function" in tool + assert "name" in tool["function"] + assert "description" in tool["function"] + assert "parameters" in tool["function"] + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.slow + @pytest.mark.asyncio + async def test_concurrent_connections(self, sse_server): + """Test multiple clients can connect simultaneously""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + async def create_and_test_client(client_id: int): + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1 + ) + + try: + await client.connect(sse_server.url) + tools = await client.get_mcp_tools() + return len(tools) + finally: + await client.disconnect() + + # Test 3 concurrent connections + tasks = [create_and_test_client(i) for i in range(3)] + results = await asyncio.gather(*tasks) + + # All should succeed and return same number of tools + assert all(r > 0 for r in results) + assert len(set(results)) == 1 # All should return same count + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_configuration_parameters(self): + """Test various configuration parameters work correctly""" + # Test different temperature settings + for temp in [0.1, 0.5, 0.9]: + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=temp, + max_tokens=100 + ) + + assert client.temperature == temp + assert client.ai_client.temperature == temp + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_resource_access(self, sse_server): + """Test accessing MCP resources""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai" + ) + + try: + await client.connect(sse_server.url) + + # Test listing resources + resources = await client.session.list_resources() + assert isinstance(resources.resources, list) + + # Test reading resources if any exist + if resources.resources: + for resource in resources.resources[:2]: # Test first 2 resources + content = await client.session.read_resource(resource.uri) + assert content is not None + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_prompt_access(self, sse_server): + """Test accessing MCP prompts""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai" + ) + + try: + await client.connect(sse_server.url) + + # Test listing prompts + prompts = await client.session.list_prompts() + assert isinstance(prompts.prompts, list) + + # Test getting prompts if any exist + if prompts.prompts: + for prompt in prompts.prompts[:2]: # Test first 2 prompts + prompt_content = await client.session.get_prompt(prompt.name) + assert prompt_content is not None + + finally: + await client.disconnect() + + +class TestMCPClientUtilities: + """Test utility functions and edge cases""" + + @pytest.mark.unit + def test_transport_type_enum(self): + """Test TransportType enum values""" + assert TransportType.SSE.value == "sse" + assert TransportType.STDIO.value == "stdio" + + @pytest.mark.unit + def test_client_initialization_validation(self): + """Test client initialization with various parameters""" + # Test with minimal parameters + client = MCPAIClient() + assert client.model == "gpt-4o" + assert client.provider == "openai" + assert client.transport == TransportType.SSE + + # Test with custom parameters + client = MCPAIClient( + model="gpt-4o-mini", + provider="openai", + transport=TransportType.STDIO, + temperature=0.5, + max_tokens=500, + top_p=0.9 + ) + + assert client.model == "gpt-4o-mini" + assert client.provider == "openai" + assert client.transport == TransportType.STDIO + assert client.temperature == 0.5 + assert client.max_tokens == 500 + + @pytest.mark.unit + @patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}) + def test_ai_client_factory_integration(self): + """Test that AI client factory creates correct client types""" + # Test OpenAI client creation + openai_client = AIClientFactory.create_client( + provider="openai", + model_name="gpt-4o", + temperature=0.5 + ) + + assert openai_client is not None + assert hasattr(openai_client, 'chat_completion') + assert hasattr(openai_client, '_format_tools_for_provider') + + @pytest.mark.unit + def test_missing_llm_client_handling(self): + """Test behavior when LLM client is not available""" + with patch('mcp_llm_client.LLM_CLIENT_AVAILABLE', False): + with pytest.raises(ImportError, match="LLM client not available"): + MCPAIClient() + + +class TestPerformanceAndLoad: + """Performance and load testing for MCP client""" + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.slow + @pytest.mark.asyncio + async def test_multiple_rapid_queries(self, sse_server): + """Test handling multiple rapid queries""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1, + max_tokens=50 + ) + + try: + await client.connect(sse_server.url) + + queries = [ + "What is 2 + 2?", + "Calculate 10 * 5", + "What tools do you have?", + "Test query 4" + ] + + # Execute queries concurrently + tasks = [client.process_query(query) for query in queries] + responses = await asyncio.gather(*tasks) + + # Verify all responses + assert len(responses) == len(queries) + assert all(isinstance(r, str) for r in responses) + assert all(len(r) > 0 for r in responses) + + finally: + await client.disconnect() + + @pytest.mark.integration + @pytest.mark.requires_api_key + @pytest.mark.asyncio + async def test_connection_reuse(self, sse_server): + """Test reusing connection for multiple operations""" + if not os.getenv("OPENAI_API_KEY"): + pytest.skip("OpenAI API key not available") + + client = MCPAIClient( + model="gpt-4o", + transport=TransportType.SSE, + provider="openai", + temperature=0.1 + ) + + try: + await client.connect(sse_server.url) + + # Perform multiple operations on same connection + for i in range(5): + tools = await client.get_mcp_tools() + assert len(tools) > 0 + + response = await client.process_query(f"Test query {i}") + assert isinstance(response, str) + + finally: + await client.disconnect() + + +# Cleanup fixture to ensure no processes are left running +@pytest.fixture(scope="session", autouse=True) +async def cleanup_processes(): + """Clean up any remaining MCP server processes""" + yield + + # Kill any remaining MCP server processes + try: + # Find and kill any remaining server processes + result = subprocess.run( + ["pgrep", "-f", "run_mcp_server.py"], + capture_output=True, + text=True + ) + + if result.returncode == 0: + pids = result.stdout.strip().split('\n') + for pid in pids: + if pid: + try: + os.kill(int(pid), signal.SIGTERM) + except (ProcessLookupError, OSError): + pass # Process already dead + + except (subprocess.SubprocessError, FileNotFoundError): + pass # pgrep not available or no processes found diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py new file mode 100644 index 0000000..365bc30 --- /dev/null +++ b/tests/unit/test_clients.py @@ -0,0 +1,202 @@ +""" +Unit tests for AI client components +""" +from unittest import async_case +import pytest +from unittest.mock import Mock, AsyncMock, patch +from mcp_template.llm_client.base_client import BaseAIClient +from mcp_template.llm_client.openai_client import OpenAIClient +from mcp_template.llm_client.client_factory import AIClientFactory + + +class TestBaseAIClient: + """Test base AI client functionality""" + + def test_base_client_creation(self): + """Test creating a base client""" + # Create a concrete implementation for testing + class TestAIClient(BaseAIClient): + async def chat_completion(self, messages, tools=None, **kwargs): + return {"choices": [{"message": {"content": "test response"}}]} + + async def _initialize_client(self): + pass + + client = TestAIClient( + model_name="test-model", + provider="test", + api_key="test-key" + ) + + assert client.model_name == "test-model" + assert client._api_key == "test-key" + assert not client._initialized + + def test_abstract_class_cannot_be_instantiated(self): + """Test that abstract BaseAIClient cannot be instantiated directly""" + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + BaseAIClient( + model_name="test-model", + provider="test", + api_key="test-key" + ) + + +class TestOpenAIClient: + """Test OpenAI client implementation""" + + def test_openai_client_creation(self): + """Test creating an OpenAI client""" + client = OpenAIClient( + model_name="gpt-4o", + api_key="test-key", + temperature=0.5, + max_tokens=500 + ) + + assert client.model_name == "gpt-4o" + assert client._temperature == 0.5 + assert client._max_tokens == 500 + + def test_openai_client_defaults(self): + """Test OpenAI client default values""" + client = OpenAIClient( + model_name="gpt-4o", + api_key="test-key" + ) + + assert client._temperature == 0.7 + assert client._max_tokens == 1000 + + @patch('mcp_template.llm_client.openai_client.AsyncOpenAI') + @pytest.mark.asyncio + async def test_openai_initialization(self, mock_openai_class): + """Test OpenAI client initialization""" + mock_client = Mock() + mock_openai_class.return_value = mock_client + + client = OpenAIClient("gpt-4o", "test-key") + await client._initialize_client() + + mock_openai_class.assert_called_once_with(api_key="test-key") + assert client._client == mock_client + + @patch('mcp_template.llm_client.openai_client.AsyncOpenAI') + @pytest.mark.asyncio + async def test_openai_chat_completion(self, mock_openai_class): + """Test OpenAI chat completion""" + # Mock the OpenAI client and response + mock_client = Mock() + mock_response = Mock() + mock_choice = Mock() + mock_message = Mock() + + mock_message.role = "assistant" + mock_message.content = "Test response" + mock_message.tool_calls = None + + mock_choice.message = mock_message + mock_response.choices = [mock_choice] + + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + mock_openai_class.return_value = mock_client + + client = OpenAIClient("gpt-4o", "test-key") + await client.initialize() + + messages = [{"role": "user", "content": "Hello"}] + result = await client.chat_completion(messages) + + # Verify the call was made correctly + mock_client.chat.completions.create.assert_called_once() + call_args = mock_client.chat.completions.create.call_args[1] + + assert call_args["model"] == "gpt-4o" + assert call_args["messages"] == messages + assert call_args["temperature"] == 0.7 + assert call_args["max_tokens"] == 1000 + + # Verify response format + assert "choices" in result + assert len(result["choices"]) == 1 + assert result["choices"][0]["message"]["content"] == "Test response" + + def test_openai_tool_formatting(self): + """Test OpenAI tool formatting""" + client = OpenAIClient("gpt-4o", "test-key") + + tools = [ + { + "name": "add", + "description": "Add two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + } + } + ] + + formatted = client._format_tools_for_provider(tools) + + assert len(formatted) == 1 + assert formatted[0]["type"] == "function" + assert formatted[0]["function"]["name"] == "add" + assert formatted[0]["function"]["description"] == "Add two numbers" + assert "parameters" in formatted[0]["function"] + + +class TestAIClientFactory: + """Test AI client factory""" + + def test_create_openai_client(self): + """Test creating OpenAI client via factory""" + client = AIClientFactory.create_openai_client( + model_name="gpt-4o", + api_key="test-key" + ) + + assert isinstance(client, OpenAIClient) + assert client.model_name == "gpt-4o" + + def test_create_client_by_provider(self): + """Test creating client by provider name""" + client = AIClientFactory.create_client( + provider="openai", + model_name="gpt-4o", + api_key="test-key" + ) + + assert isinstance(client, OpenAIClient) + assert client.model_name == "gpt-4o" + + def test_invalid_provider(self): + """Test creating client with invalid provider""" + with pytest.raises(ValueError, match="Unsupported AI provider"): + AIClientFactory.create_client("invalid", "model", "key") + + @patch('mcp_template.llm_client.client_factory.CONFIG_AVAILABLE', True) + @patch('mcp_template.llm_client.client_factory.Config') + def test_missing_api_key(self, mock_config): + """Test creating client without API key""" + # Mock config to return None for API key + mock_config.OPENAI_API_KEY = None + + with pytest.raises(ValueError, match="API key not provided and could not be loaded from config for provider: openai"): + AIClientFactory.create_client("openai", "gpt-4o") + + def test_available_providers(self): + """Test getting available providers""" + providers = AIClientFactory.get_available_providers() + assert "openai" in providers + assert "claude" in providers + assert "grok" in providers + + def test_validate_provider(self): + """Test provider validation""" + assert AIClientFactory.validate_provider("openai") is True + assert AIClientFactory.validate_provider("claude") is True + assert AIClientFactory.validate_provider("invalid") is False diff --git a/tests/unit/test_core_types.py b/tests/unit/test_core_types.py new file mode 100644 index 0000000..581df47 --- /dev/null +++ b/tests/unit/test_core_types.py @@ -0,0 +1,213 @@ +""" +Unit tests for core MCP types +""" +import pytest +from mcp_template.core.types import ( + TransportType, + MCPTool, + MCPResource, + MCPPrompt, + MCPServerConfig +) + + +class TestTransportType: + """Test TransportType enum""" + + def test_transport_type_values(self): + """Test transport type enum values""" + assert TransportType.SSE.value == "sse" + assert TransportType.STDIO.value == "stdio" + + def test_transport_type_from_string(self): + """Test creating transport type from string""" + assert TransportType("sse") == TransportType.SSE + assert TransportType("stdio") == TransportType.STDIO + assert TransportType("SSE") == TransportType.SSE + + +class TestMCPTool: + """Test MCPTool dataclass""" + + def test_tool_creation(self): + """Test creating a valid tool""" + async def sample_handler(a: int, b: int) -> int: + return a + b + + tool = MCPTool( + name="add", + description="Add two numbers", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + }, + handler=sample_handler + ) + + assert tool.name == "add" + assert tool.description == "Add two numbers" + assert "a" in tool.input_schema["properties"] + + def test_tool_validation(self): + """Test tool validation""" + async def handler(): + pass + + # Valid tool + tool = MCPTool( + name="test", + description="Test tool", + input_schema={}, + handler=handler + ) + assert tool.name == "test" + + # Invalid tool - empty name + with pytest.raises(ValueError): + MCPTool( + name="", + description="Test tool", + input_schema={}, + handler=handler + ) + + # Invalid tool - empty description + with pytest.raises(ValueError): + MCPTool( + name="test", + description="", + input_schema={}, + handler=handler + ) + + +class TestMCPResource: + """Test MCPResource dataclass""" + + def test_resource_creation(self): + """Test creating a valid resource""" + resource = MCPResource( + uri="file:///test.txt", + name="Test File", + description="A test file", + mime_type="text/plain", + content="Hello, world!" + ) + + assert resource.uri == "file:///test.txt" + assert resource.name == "Test File" + assert resource.content == "Hello, world!" + + def test_resource_validation(self): + """Test resource validation""" + # Valid resource + resource = MCPResource( + uri="file:///test.txt", + name="Test", + description="Test resource", + mime_type="text/plain", + content=b"test" + ) + assert resource.uri == "file:///test.txt" + + # Invalid resource - empty URI + with pytest.raises(ValueError): + MCPResource( + uri="", + name="Test", + description="Test resource", + mime_type="text/plain", + content="test" + ) + + # Invalid resource - empty name + with pytest.raises(ValueError): + MCPResource( + uri="file:///test.txt", + name="", + description="Test resource", + mime_type="text/plain", + content="test" + ) + + +class TestMCPPrompt: + """Test MCPPrompt dataclass""" + + def test_prompt_creation(self): + """Test creating a valid prompt""" + prompt = MCPPrompt( + name="greeting", + description="A greeting prompt", + template="Hello, {name}! Welcome to {place}.", + arguments={ + "name": {"type": "string", "description": "Person's name"}, + "place": {"type": "string", "description": "Place name"} + } + ) + + assert prompt.name == "greeting" + assert "name" in prompt.template + assert prompt.arguments is not None + + def test_prompt_validation(self): + """Test prompt validation""" + # Valid prompt + prompt = MCPPrompt( + name="test", + description="Test prompt", + template="Hello, world!" + ) + assert prompt.name == "test" + + # Invalid prompt - empty name + with pytest.raises(ValueError): + MCPPrompt( + name="", + description="Test prompt", + template="Hello, world!" + ) + + # Invalid prompt - empty template + with pytest.raises(ValueError): + MCPPrompt( + name="test", + description="Test prompt", + template="" + ) + + +class TestMCPServerConfig: + """Test MCPServerConfig dataclass""" + + def test_server_config_creation(self): + """Test creating a valid server config""" + config = MCPServerConfig( + name="Test Server", + version="1.0.0", + transport=TransportType.SSE, + host="localhost", + port=8080, + stateless_http=True + ) + + assert config.name == "Test Server" + assert config.transport == TransportType.SSE + assert config.port == 8080 + assert config.tools == [] + assert config.resources == [] + assert config.prompts == [] + + def test_server_config_defaults(self): + """Test server config default values""" + config = MCPServerConfig(name="Test Server") + + assert config.version == "1.0.0" + assert config.transport == TransportType.STDIO + assert config.host == "0.0.0.0" + assert config.port == 8050 + assert config.stateless_http is True diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py new file mode 100644 index 0000000..a1a1c37 --- /dev/null +++ b/tests/unit/test_server.py @@ -0,0 +1,495 @@ +""" +Unit tests for MCP server components +""" +import pytest +import asyncio +import tempfile +import os +from unittest.mock import Mock, AsyncMock, patch, MagicMock +from pathlib import Path +from mcp_template.core.types import MCPTool, MCPResource, MCPPrompt, MCPServerConfig, TransportType +# FastMCPServer not available, using ModularMCPServer for testing +from mcp_template.server.server_factory import MCPServerFactory +from mcp_template.server.modular_server import ModularMCPServer +from mcp_template.server.tools.tool_registry import ServerToolRegistry +from mcp_template.server.prompts.prompt_registry import ServerPromptRegistry +from mcp_template.server.resources.resource_registry import ServerResourceRegistry +from mcp_template.server.tools.base_tool import BaseServerTool, ContextAwareTool +from mcp_template.server.prompts.base_prompt import BaseServerPrompt +from mcp_template.server.resources.base_resource import BaseServerResource + + +class TestModularMCPServerBasic: + """Test basic ModularMCP server functionality (replacing FastMCPServer tests)""" + + @pytest.fixture + def sample_tool(self): + """Create a sample tool for testing""" + async def add_handler(a: int, b: int) -> int: + return a + b + + return MCPTool( + name="add", + description="Add two numbers", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + }, + handler=add_handler + ) + + @pytest.fixture + def server_config(self): + """Create a sample server config""" + return MCPServerConfig( + name="Test Server", + transport=TransportType.STDIO, + host="localhost", + port=8050 + ) + + @pytest.mark.asyncio + async def test_server_initialization(self, server_config, sample_tool): + """Test server initialization""" + # Use factory to create server + server = MCPServerFactory.create_server( + name="Test Server", + transport="stdio" + ) + + # Server should not be initialized yet + assert not server._initialized + + # Initialize server + await server.initialize() + + # Server should be initialized + assert server._initialized + + @pytest.mark.asyncio + async def test_server_info(self, server_config): + """Test getting server information""" + server = MCPServerFactory.create_server( + name="Test Server", + transport="stdio" + ) + + # Test server info before initialization + info = server.get_server_info() + assert info["name"] == "Test Server" + assert info["host"] == "0.0.0.0" + assert info["port"] == 8050 + + def test_server_run_method(self, server_config): + """Test server run method""" + server = MCPServerFactory.create_server( + name="Test Server", + transport="stdio" + ) + + # Mock the run method to avoid actually starting the server + with patch.object(server.mcp, 'run') as mock_run: + server.run("stdio") + mock_run.assert_called_once_with(transport="stdio") + + + +class TestMCPServerFactory: + """Test MCP server factory""" + + def test_create_server_basic(self): + """Test creating a basic server""" + server = MCPServerFactory.create_server( + name="Test Server", + transport="stdio" + ) + + assert isinstance(server, ModularMCPServer) + assert server.name == "Test Server" + assert server.host == "0.0.0.0" + assert server.port == 8050 + + def test_create_calculator_server(self): + """Test creating a calculator server""" + server = MCPServerFactory.create_basic_calculator_server( + name="Calculator Server", + transport="sse", + port=8080 + ) + + assert isinstance(server, ModularMCPServer) + assert server.name == "Calculator Server" + assert server.host == "0.0.0.0" + assert server.port == 8080 + + # For ModularMCPServer, we can't easily test tool registration without mocking + # The tools would be registered through the registry system + assert server.tool_registry is not None + + def test_create_knowledge_base_server(self): + """Test creating a knowledge base server""" + kb_data = { + "policy": "Company vacation policy", + "faq": "Frequently asked questions" + } + + server = MCPServerFactory.create_knowledge_base_server( + name="KB Server", + kb_data=kb_data + ) + + assert isinstance(server, ModularMCPServer) + assert server.name == "KB Server" + + # For ModularMCPServer, we can't easily test tool registration without mocking + # The tools would be registered through the registry system + assert server.tool_registry is not None + + +class TestModularMCPServer: + """Test ModularMCP server implementation""" + + @pytest.fixture + def modular_server(self): + """Create a modular server for testing""" + return ModularMCPServer( + name="Test Modular Server", + host="localhost", + port=8051 + ) + + @pytest.mark.asyncio + async def test_modular_server_initialization(self, modular_server): + """Test modular server initialization""" + assert modular_server.name == "Test Modular Server" + assert modular_server.host == "localhost" + assert modular_server.port == 8051 + assert not modular_server._initialized + assert modular_server.tool_registry is not None + assert modular_server.prompt_registry is not None + assert modular_server.resource_registry is not None + + @pytest.mark.asyncio + async def test_modular_server_initialize(self, modular_server): + """Test modular server initialization process""" + # Mock the registry methods to avoid actual file discovery + with patch.object(modular_server.tool_registry, 'register_tools_with_server') as mock_tools, \ + patch.object(modular_server.tool_registry, 'get_all_tools', return_value=[]), \ + patch.object(modular_server.prompt_registry, 'register_prompts_with_server') as mock_prompts, \ + patch.object(modular_server.prompt_registry, 'get_all_prompts', return_value=[]), \ + patch.object(modular_server.resource_registry, 'register_resources_with_server') as mock_resources, \ + patch.object(modular_server.resource_registry, 'get_all_resources', return_value=[]): + + await modular_server.initialize() + + assert modular_server._initialized + mock_tools.assert_called_once_with(modular_server.mcp) + mock_prompts.assert_called_once_with(modular_server.mcp) + mock_resources.assert_called_once_with(modular_server.mcp) + + @pytest.mark.asyncio + async def test_modular_server_double_initialize(self, modular_server): + """Test that double initialization doesn't cause issues""" + with patch.object(modular_server.tool_registry, 'register_tools_with_server') as mock_tools, \ + patch.object(modular_server.tool_registry, 'get_all_tools', return_value=[]), \ + patch.object(modular_server.prompt_registry, 'register_prompts_with_server') as mock_prompts, \ + patch.object(modular_server.prompt_registry, 'get_all_prompts', return_value=[]), \ + patch.object(modular_server.resource_registry, 'register_resources_with_server') as mock_resources, \ + patch.object(modular_server.resource_registry, 'get_all_resources', return_value=[]): + + # Initialize twice + await modular_server.initialize() + await modular_server.initialize() + + # Should only be called once due to _initialized check + mock_tools.assert_called_once() + mock_prompts.assert_called_once() + mock_resources.assert_called_once() + + def test_modular_server_get_server_info(self, modular_server): + """Test getting server information""" + # Mock the registry methods + with patch.object(modular_server.tool_registry, 'get_all_tools', return_value=[Mock(name="tool1")]), \ + patch.object(modular_server.tool_registry, 'get_tool_names', return_value=["tool1"]), \ + patch.object(modular_server.prompt_registry, 'get_all_prompts', return_value=[Mock(name="prompt1")]), \ + patch.object(modular_server.prompt_registry, 'get_prompt_names', return_value=["prompt1"]), \ + patch.object(modular_server.resource_registry, 'get_all_resources', return_value=[Mock(uri="resource1")]), \ + patch.object(modular_server.resource_registry, 'get_resource_uris', return_value=["resource1"]): + + info = modular_server.get_server_info() + + assert info["name"] == "Test Modular Server" + assert info["host"] == "localhost" + assert info["port"] == 8051 + assert info["tools"]["count"] == 1 + assert info["tools"]["names"] == ["tool1"] + assert info["prompts"]["count"] == 1 + assert info["prompts"]["names"] == ["prompt1"] + assert info["resources"]["count"] == 1 + assert info["resources"]["uris"] == ["resource1"] + + def test_modular_server_run_without_initialize(self, modular_server): + """Test that run method initializes server if not already initialized""" + with patch.object(modular_server, 'initialize') as mock_init, \ + patch.object(modular_server.mcp, 'run') as mock_run: + + modular_server.run("stdio") + + mock_init.assert_called_once() + mock_run.assert_called_once_with(transport="stdio") + + def test_modular_server_run_already_initialized(self, modular_server): + """Test that run method doesn't reinitialize if already initialized""" + modular_server._initialized = True + + with patch.object(modular_server, 'initialize') as mock_init, \ + patch.object(modular_server.mcp, 'run') as mock_run: + + modular_server.run("sse") + + mock_init.assert_not_called() + mock_run.assert_called_once_with(transport="sse") + + def test_modular_server_custom_directories(self): + """Test modular server with custom directories""" + server = ModularMCPServer( + name="Custom Server", + tools_directory="/custom/tools", + prompts_directory="/custom/prompts", + resources_directory="/custom/resources" + ) + + assert server.tool_registry.directory == "/custom/tools" + assert server.prompt_registry.directory == "/custom/prompts" + assert server.resource_registry.directory == "/custom/resources" + + +class TestServerRegistries: + """Test server registry classes""" + + @pytest.fixture + def mock_tool(self): + """Create a mock tool for testing""" + tool = Mock() + tool.name = "test_tool" + tool.description = "A test tool" + tool.input_schema = {"type": "object"} + return tool + + @pytest.fixture + def mock_prompt(self): + """Create a mock prompt for testing""" + prompt = Mock() + prompt.name = "test_prompt" + prompt.description = "A test prompt" + prompt.arguments = {"name": {"type": "string"}} + return prompt + + @pytest.fixture + def mock_resource(self): + """Create a mock resource for testing""" + resource = Mock() + resource.uri = "test://resource" + resource.name = "Test Resource" + resource.description = "A test resource" + return resource + + def test_tool_registry_initialization(self): + """Test tool registry initialization""" + registry = ServerToolRegistry("/test/tools") + assert registry.directory == "/test/tools" + assert registry._tools == {} + + def test_tool_registry_register_tool(self, mock_tool): + """Test tool registration""" + registry = ServerToolRegistry() + registry.register_tool(mock_tool) + + assert "test_tool" in registry._tools + assert registry._tools["test_tool"] == mock_tool + + def test_tool_registry_get_all_tools(self, mock_tool): + """Test getting all tools""" + registry = ServerToolRegistry() + registry.register_tool(mock_tool) + + tools = registry.get_all_tools() + assert len(tools) == 1 + assert tools[0] == mock_tool + + def test_tool_registry_get_tool_names(self, mock_tool): + """Test getting tool names""" + registry = ServerToolRegistry() + registry.register_tool(mock_tool) + + names = registry.get_tool_names() + assert names == ["test_tool"] + + def test_prompt_registry_initialization(self): + """Test prompt registry initialization""" + registry = ServerPromptRegistry("/test/prompts") + assert registry.directory == "/test/prompts" + assert registry._prompts == {} + + def test_prompt_registry_register_prompt(self, mock_prompt): + """Test prompt registration""" + registry = ServerPromptRegistry() + registry.register_prompt(mock_prompt) + + assert "test_prompt" in registry._prompts + assert registry._prompts["test_prompt"] == mock_prompt + + def test_prompt_registry_get_all_prompts(self, mock_prompt): + """Test getting all prompts""" + registry = ServerPromptRegistry() + registry.register_prompt(mock_prompt) + + prompts = registry.get_all_prompts() + assert len(prompts) == 1 + assert prompts[0] == mock_prompt + + def test_prompt_registry_get_prompt_names(self, mock_prompt): + """Test getting prompt names""" + registry = ServerPromptRegistry() + registry.register_prompt(mock_prompt) + + names = registry.get_prompt_names() + assert names == ["test_prompt"] + + def test_resource_registry_initialization(self): + """Test resource registry initialization""" + registry = ServerResourceRegistry("/test/resources") + assert registry.directory == "/test/resources" + assert registry._resources == {} + + def test_resource_registry_register_resource(self, mock_resource): + """Test resource registration""" + registry = ServerResourceRegistry() + registry.register_resource(mock_resource) + + assert "test://resource" in registry._resources + assert registry._resources["test://resource"] == mock_resource + + def test_resource_registry_get_all_resources(self, mock_resource): + """Test getting all resources""" + registry = ServerResourceRegistry() + registry.register_resource(mock_resource) + + resources = registry.get_all_resources() + assert len(resources) == 1 + assert resources[0] == mock_resource + + def test_resource_registry_get_resource_uris(self, mock_resource): + """Test getting resource URIs""" + registry = ServerResourceRegistry() + registry.register_resource(mock_resource) + + uris = registry.get_resource_uris() + assert uris == ["test://resource"] + + +class TestBaseServerTools: + """Test base server tool classes""" + + def test_base_server_tool_initialization(self): + """Test BaseServerTool initialization""" + class TestTool(BaseServerTool): + async def execute(self, **kwargs): + return "test_result" + + tool = TestTool( + name="test_tool", + description="A test tool", + input_schema={"type": "object"} + ) + + assert tool.name == "test_tool" + assert tool.description == "A test tool" + assert tool.input_schema == {"type": "object"} + + def test_base_server_tool_get_tool_definition(self): + """Test getting tool definition""" + class TestTool(BaseServerTool): + async def execute(self, **kwargs): + return "test_result" + + tool = TestTool( + name="test_tool", + description="A test tool", + input_schema={"type": "object"} + ) + + definition = tool.get_tool_definition() + expected = { + "name": "test_tool", + "description": "A test tool", + "input_schema": {"type": "object"} + } + + assert definition == expected + + def test_base_server_tool_create_fastmcp_tool(self): + """Test creating FastMCP tool wrapper""" + class TestTool(BaseServerTool): + async def execute(self, **kwargs): + return "test_result" + + tool = TestTool( + name="test_tool", + description="A test tool", + input_schema={"type": "object"} + ) + + # Mock MCP server + mock_mcp = Mock() + mock_mcp.tool.return_value = lambda func: func + + wrapper = tool.create_fastmcp_tool(mock_mcp) + + assert wrapper.__name__ == "test_tool" + assert wrapper.__doc__ == "A test tool" + + def test_context_aware_tool_initialization(self): + """Test ContextAwareTool initialization""" + class TestContextTool(ContextAwareTool): + async def execute_with_context(self, ctx, **kwargs): + return "context_result" + + tool = TestContextTool( + name="context_tool", + description="A context-aware tool", + input_schema={"type": "object"} + ) + + assert tool.name == "context_tool" + assert tool.description == "A context-aware tool" + assert tool.input_schema == {"type": "object"} + + @pytest.mark.asyncio + async def test_context_aware_tool_execute_with_context(self): + """Test ContextAwareTool execution with context""" + from unittest.mock import Mock + + class TestContextTool(ContextAwareTool): + async def execute_with_context(self, ctx, **kwargs): + return f"Context: {self.context}, Args: {kwargs}" + + tool = TestContextTool( + name="context_tool", + description="A context-aware tool", + input_schema={"type": "object"} + ) + + # Set context + tool.context = {"user_id": "123"} + + # Mock the context + mock_ctx = Mock() + + result = await tool.execute_with_context(mock_ctx, test_arg="value") + assert "Context: {'user_id': '123'}" in result + assert "Args: {'test_arg': 'value'}" in result diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cb65db1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,594 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "jiter" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617, upload-time = "2025-05-18T19:04:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947, upload-time = "2025-05-18T19:04:03.347Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618, upload-time = "2025-05-18T19:04:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829, upload-time = "2025-05-18T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034, upload-time = "2025-05-18T19:04:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529, upload-time = "2025-05-18T19:04:09.566Z" }, + { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671, upload-time = "2025-05-18T19:04:10.98Z" }, + { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864, upload-time = "2025-05-18T19:04:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989, upload-time = "2025-05-18T19:04:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495, upload-time = "2025-05-18T19:04:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289, upload-time = "2025-05-18T19:04:17.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074, upload-time = "2025-05-18T19:04:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225, upload-time = "2025-05-18T19:04:20.583Z" }, + { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235, upload-time = "2025-05-18T19:04:22.363Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278, upload-time = "2025-05-18T19:04:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866, upload-time = "2025-05-18T19:04:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772, upload-time = "2025-05-18T19:04:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534, upload-time = "2025-05-18T19:04:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087, upload-time = "2025-05-18T19:04:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694, upload-time = "2025-05-18T19:04:30.183Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992, upload-time = "2025-05-18T19:04:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723, upload-time = "2025-05-18T19:04:33.467Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215, upload-time = "2025-05-18T19:04:34.827Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762, upload-time = "2025-05-18T19:04:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427, upload-time = "2025-05-18T19:04:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127, upload-time = "2025-05-18T19:04:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527, upload-time = "2025-05-18T19:04:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/fd/d6e941a52446198b73e5e4a953441f667f1469aeb06fb382d9f6729d6168/mcp-1.14.0.tar.gz", hash = "sha256:2e7d98b195e08b2abc1dc6191f6f3dc0059604ac13ee6a40f88676274787fac4", size = 454855, upload-time = "2025-09-11T17:40:48.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/7b/84b0dd4c2c5a499d2c5d63fb7a1224c25fc4c8b6c24623fa7a566471480d/mcp-1.14.0-py3-none-any.whl", hash = "sha256:b2d27feba27b4c53d41b58aa7f4d090ae0cb740cbc4e339af10f8cbe54c4e19d", size = 163805, upload-time = "2025-09-11T17:40:46.891Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mcp-template" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mcp", extra = ["cli"] }, + { name = "nest-asyncio" }, + { name = "openai" }, + { name = "psutil" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", extras = ["cli"], specifier = ">=1.14.0" }, + { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "openai", specifier = ">=1.107.1" }, + { name = "psutil" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "openai" +version = "1.107.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/e0/a62daa7ff769df969cc1b782852cace79615039630b297005356f5fb46fb/openai-1.107.1.tar.gz", hash = "sha256:7c51b6b8adadfcf5cada08a613423575258b180af5ad4bc2954b36ebc0d3ad48", size = 563671, upload-time = "2025-09-10T15:04:40.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/12/32c19999a58eec4a695e8ce334442b6135df949f0bb61b2ceaa4fa60d3a9/openai-1.107.1-py3-none-any.whl", hash = "sha256:168f9885b1b70d13ada0868a0d0adfd538c16a02f7fd9fe063851a2c9a025e72", size = 945177, upload-time = "2025-09-10T15:04:37.782Z" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typer" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +]