├── .env.example ├── .gitignore ├── README.md ├── config.yml ├── dolphin_mcp.py ├── examples ├── README.md ├── filesystem-fetch-mcp.json ├── sqlite-mcp.json └── stocklist.txt ├── pyproject.toml ├── requirements.txt ├── setup_db.py └── src └── dolphin_mcp ├── __init__.py ├── cli.py ├── client.py ├── providers ├── __init__.py ├── anthropic.py ├── lmstudio.py ├── msazureopenai.py ├── ollama.py └── openai.py └── utils.py /.env.example: -------------------------------------------------------------------------------- 1 | # OpenAI API Configuration 2 | OPENAI_API_KEY=your_openai_api_key_here 3 | OPENAI_MODEL=gpt-4o 4 | # OPENAI_ENDPOINT=https://api.openai.com/v1 # Uncomment and modify if using a custom endpoint 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python artifacts 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Environment variables 24 | .env 25 | .venv 26 | env/ 27 | venv/ 28 | ENV/ 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .coverage.* 35 | .cache 36 | nosetests.xml 37 | coverage.xml 38 | *.cover 39 | .hypothesis/ 40 | .pytest_cache/ 41 | 42 | # Jupyter Notebook 43 | .ipynb_checkpoints 44 | 45 | # Editor directories and files 46 | .idea/ 47 | .vscode/ 48 | *.swp 49 | *.swo 50 | 51 | # OS specific 52 | .DS_Store 53 | .DS_Store? 54 | ._* 55 | .Spotlight-V100 56 | .Trashes 57 | ehthumbs.db 58 | Thumbs.db 59 | 60 | # Project specific 61 | *.db 62 | *.sqlite 63 | *.sqlite3 64 | ~/.dolphin/ 65 | /examples/dolphin_db_config.json 66 | *.jsonl 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dolphin MCP 2 | 3 | A flexible Python library and CLI tool for interacting with Model Context Protocol (MCP) servers using any LLM model. 4 | 5 | ![image](https://github.com/user-attachments/assets/d0ee1159-2a8f-454d-8fba-cf692f425af9) 6 | 7 | ## Overview 8 | 9 | Dolphin MCP is both a Python library and a command-line tool that allows you to query and interact with MCP servers through natural language. It connects to any number of configured MCP servers, makes their tools available to language models (OpenAI, Anthropic, Ollama, LMStudio), and provides a conversational interface for accessing and manipulating data from these servers. 10 | 11 | The project demonstrates how to: 12 | - Connect to multiple MCP servers simultaneously 13 | - List and call tools provided by these servers 14 | - Use function calling capabilities to interact with external data sources 15 | - Process and present results in a user-friendly way 16 | - Create a reusable Python library with a clean API 17 | - Build a command-line interface on top of the library 18 | 19 | ## Features 20 | 21 | - **Multiple Provider Support**: Works with OpenAI, Anthropic, Ollama, and LMStudio models 22 | - **Modular Architecture**: Clean separation of concerns with provider-specific modules 23 | - **Dual Interface**: Use as a Python library or command-line tool 24 | - **MCP Server Integration**: Connect to any number of MCP servers simultaneously 25 | - **Tool Discovery**: Automatically discover and use tools provided by MCP servers 26 | - **Flexible Configuration**: Configure models and servers through JSON configuration 27 | - **Environment Variable Support**: Securely store API keys in environment variables 28 | - **Comprehensive Documentation**: Detailed usage examples and API documentation 29 | - **Installable Package**: Easy installation via pip with `dolphin-mcp-cli` command 30 | 31 | ## Prerequisites 32 | 33 | Before installing Dolphin MCP, ensure you have the following prerequisites installed: 34 | 35 | 1. **Python 3.10+** 36 | 2. **SQLite** - A lightweight database used by the demo 37 | 3. **uv/uvx** - A fast Python package installer and resolver 38 | 39 | ### Setting up Prerequisites 40 | 41 | #### Windows 42 | 43 | 1. **Python 3.10+**: 44 | - Download and install from [python.org](https://www.python.org/downloads/windows/) 45 | - Ensure you check "Add Python to PATH" during installation 46 | 47 | 2. **SQLite**: 48 | - Download the precompiled binaries from [SQLite website](https://www.sqlite.org/download.html) 49 | - Choose the "Precompiled Binaries for Windows" section and download the sqlite-tools zip file 50 | - Extract the files to a folder (e.g., `C:\sqlite`) 51 | - Add this folder to your PATH: 52 | - Open Control Panel > System > Advanced System Settings > Environment Variables 53 | - Edit the PATH variable and add the path to your SQLite folder 54 | - Verify installation by opening Command Prompt and typing `sqlite3 --version` 55 | 56 | 3. **uv/uvx**: 57 | - Open PowerShell as Administrator and run: 58 | ``` 59 | powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 60 | ``` 61 | - Restart your terminal and verify installation with `uv --version` 62 | 63 | #### macOS 64 | 65 | 1. **Python 3.10+**: 66 | - Install using Homebrew: 67 | ``` 68 | brew install python 69 | ``` 70 | 71 | 2. **SQLite**: 72 | - SQLite comes pre-installed on macOS, but you can update it using Homebrew: 73 | ``` 74 | brew install sqlite 75 | ``` 76 | - Verify installation with `sqlite3 --version` 77 | 78 | 3. **uv/uvx**: 79 | - Install using Homebrew: 80 | ``` 81 | brew install uv 82 | ``` 83 | - Or use the official installer: 84 | ``` 85 | curl -LsSf https://astral.sh/uv/install.sh | sh 86 | ``` 87 | - Verify installation with `uv --version` 88 | 89 | #### Linux (Ubuntu/Debian) 90 | 91 | 1. **Python 3.10+**: 92 | ``` 93 | sudo apt update 94 | sudo apt install python3 python3-pip 95 | ``` 96 | 97 | 2. **SQLite**: 98 | ``` 99 | sudo apt update 100 | sudo apt install sqlite3 101 | ``` 102 | - Verify installation with `sqlite3 --version` 103 | 104 | 3. **uv/uvx**: 105 | ``` 106 | curl -LsSf https://astral.sh/uv/install.sh | sh 107 | ``` 108 | - Verify installation with `uv --version` 109 | 110 | ## Installation 111 | 112 | ### Option 1: Install from PyPI (Recommended) 113 | 114 | ```bash 115 | pip install dolphin-mcp 116 | ``` 117 | 118 | This will install both the library and the `dolphin-mcp-cli` command-line tool. 119 | 120 | ### Option 2: Install from Source 121 | 122 | 1. Clone this repository: 123 | ```bash 124 | git clone https://github.com/cognitivecomputations/dolphin-mcp.git 125 | cd dolphin-mcp 126 | ``` 127 | 128 | 2. Install the package in development mode: 129 | ```bash 130 | pip install -e . 131 | ``` 132 | 133 | 3. Set up your environment variables by copying the example file and adding your OpenAI API key: 134 | ```bash 135 | cp .env.example .env 136 | ``` 137 | Then edit the `.env` file to add your OpenAI API key. 138 | 139 | 4. (Optional) Set up the demo dolphin database: 140 | ```bash 141 | python setup_db.py 142 | ``` 143 | This creates a sample SQLite database with dolphin information that you can use to test the system. 144 | 145 | ## Configuration 146 | 147 | The project uses two main configuration files: 148 | 149 | 1. `.env` - Contains OpenAI API configuration: 150 | ``` 151 | OPENAI_API_KEY=your_openai_api_key_here 152 | OPENAI_MODEL=gpt-4o 153 | # OPENAI_BASE_URL=https://api.openai.com/v1 # Uncomment and modify if using a custom base url 154 | ``` 155 | 156 | 2. `mcp_config.json` - Defines MCP servers to connect to: 157 | ```json 158 | { 159 | "mcpServers": { 160 | "server1": { 161 | "command": "command-to-start-server", 162 | "args": ["arg1", "arg2"], 163 | "env": { 164 | "ENV_VAR1": "value1", 165 | "ENV_VAR2": "value2" 166 | } 167 | }, 168 | "server2": { 169 | "command": "another-server-command", 170 | "args": ["--option", "value"] 171 | } 172 | } 173 | } 174 | ``` 175 | 176 | You can add as many MCP servers as you need, and the client will connect to all of them and make their tools available. 177 | 178 | ## Usage 179 | 180 | ### Using the CLI Command 181 | 182 | Run the CLI command with your query as an argument: 183 | 184 | ```bash 185 | dolphin-mcp-cli "Your query here" 186 | ``` 187 | 188 | ### Command-line Options 189 | 190 | ``` 191 | Usage: dolphin-mcp-cli [--model ] [--quiet] [--interactive | -i] [--config ] [--mcp-config ] [--log-messages ] [--debug] ['your question'] 192 | 193 | Options: 194 | --model Specify the model to use (e.g., gpt-4o, dolphin, qwen2.5-7b) 195 | --quiet Suppress intermediate output (except errors) 196 | --interactive, -i Enable interactive chat mode. If selected, 'your question' argument is optional for the first turn. 197 | --config Specify a custom config file for LLM providers (default: config.yml) 198 | --mcp-config Specify a custom config file for MCP servers (default: examples/sqlite-mcp.json) 199 | --log-messages Log all LLM interactions to a JSONL file 200 | --debug Enable debug logging (Note: `cli.py` sets DEBUG level by default currently) 201 | --help, -h Show this help message 202 | ``` 203 | 204 | ### Interactive Chat Mode 205 | 206 | To start `dolphin-mcp-cli` in interactive mode, use the `--interactive` or `-i` flag: 207 | 208 | ```bash 209 | dolphin-mcp-cli --interactive 210 | # or 211 | dolphin-mcp-cli -i 212 | ``` 213 | 214 | You can also provide an initial question: 215 | 216 | ```bash 217 | dolphin-mcp-cli -i "What dolphin species are endangered?" 218 | ``` 219 | 220 | In interactive mode, you can have a continuous conversation with the configured model. The chat will maintain context from previous turns. Type `exit` or `quit` to end the session. 221 | 222 | ### Using the Library Programmatically 223 | 224 | You can also use Dolphin MCP as a library in your Python code: 225 | 226 | ```python 227 | import asyncio 228 | from dolphin_mcp import run_interaction 229 | 230 | async def main(): 231 | result = await run_interaction( 232 | user_query="What dolphin species are endangered?", 233 | model_name="gpt-4o", # Optional, will use default from config if not specified 234 | config_path="mcp_config.json", # Optional, defaults to mcp_config.json 235 | quiet_mode=False # Optional, defaults to False 236 | ) 237 | print(result) 238 | 239 | # Run the async function 240 | asyncio.run(main()) 241 | ``` 242 | 243 | ### Using the Original Script (Legacy) 244 | 245 | You can still run the original script directly: 246 | 247 | ```bash 248 | python dolphin_mcp.py "Your query here" 249 | ``` 250 | 251 | The tool will: 252 | 1. Connect to all configured MCP servers 253 | 2. List available tools from each server 254 | 3. Call the language model API with your query and the available tools 255 | 4. Execute any tool calls requested by the model 256 | 5. Return the results in a conversational format 257 | 258 | ## Example Queries 259 | 260 | Examples will depend on the MCP servers you have configured. With the demo dolphin database: 261 | 262 | ```bash 263 | dolphin-mcp-cli --mcp-config examples/sqlite-mcp.json --model gpt-4o "What dolphin species are endangered?" 264 | ``` 265 | 266 | Or with your own custom MCP servers: 267 | 268 | ```bash 269 | dolphin-mcp-cli "Query relevant to your configured servers" 270 | ``` 271 | 272 | You can also specify a model to use: 273 | 274 | ```bash 275 | dolphin-mcp-cli --model gpt-4o "What are the evolutionary relationships between dolphin species?" 276 | ``` 277 | 278 | To use the LMStudio provider: 279 | 280 | ```bash 281 | dolphin-mcp-cli --model qwen2.5-7b "What are the evolutionary relationships between dolphin species?" 282 | ``` 283 | 284 | For quieter output (suppressing intermediate results): 285 | 286 | ```bash 287 | dolphin-mcp-cli --quiet "List all dolphin species in the Atlantic Ocean" 288 | ``` 289 | 290 | For more detailed examples and use cases, please refer to the [examples README](./examples/README.md). 291 | 292 | ## Demo Database 293 | 294 | If you run `setup_db.py`, it will create a sample SQLite database with information about dolphin species. This is provided as a demonstration of how the system works with a simple MCP server. The database includes: 295 | 296 | - Information about various dolphin species 297 | - Evolutionary relationships between species 298 | - Conservation status and physical characteristics 299 | 300 | This is just one example of what you can do with the Dolphin MCP client. You can connect it to any MCP server that provides tools for accessing different types of data or services. 301 | 302 | ## Requirements 303 | 304 | - Python 3.10+ 305 | - OpenAI API key (or other supported provider API keys) 306 | 307 | ### Core Dependencies 308 | - openai 309 | - mcp[cli] 310 | - python-dotenv 311 | - anthropic 312 | - ollama 313 | - lmstudio 314 | - jsonschema 315 | 316 | ### Development Dependencies 317 | - pytest 318 | - pytest-asyncio 319 | - pytest-mock 320 | - uv 321 | 322 | ### Demo Dependencies 323 | - mcp-server-sqlite 324 | 325 | All dependencies are automatically installed when you install the package using pip. 326 | 327 | ## How It Works 328 | 329 | ### Package Structure 330 | 331 | The package is organized into several modules: 332 | 333 | - `dolphin_mcp/` - Main package directory 334 | - `__init__.py` - Package initialization and exports 335 | - `client.py` - Core MCPClient implementation and run_interaction function 336 | - `cli.py` - Command-line interface 337 | - `utils.py` - Utility functions for configuration and argument parsing 338 | - `providers/` - Provider-specific implementations 339 | - `openai.py` - OpenAI API integration 340 | - `anthropic.py` - Anthropic API integration 341 | - `ollama.py` - Ollama API integration 342 | - `lmstudio.py` - LMStudio SDK integration 343 | 344 | ### Execution Flow 345 | 346 | 1. The CLI parses command-line arguments and calls the library's `run_interaction` function. 347 | 2. The library loads configuration from `mcp_config.json` and connects to each configured MCP server. 348 | 3. It retrieves the list of available tools from each server and formats them for the language model's API. 349 | 4. The user's query is sent to the selected language model (OpenAI, Anthropic, Ollama, or LMStudio) along with the available tools. 350 | 5. If the model decides to call a tool, the library routes the call to the appropriate server and returns the result. 351 | 6. This process continues until the model has all the information it needs to provide a final response. 352 | 353 | This modular architecture allows for great flexibility - you can add any MCP server that provides tools for accessing different data sources or services, and the client will automatically make those tools available to the language model. The provider-specific modules also make it easy to add support for additional language model providers in the future. 354 | 355 | ## Contributing 356 | 357 | Contributions are welcome! Please feel free to submit a Pull Request. 358 | 359 | ## License 360 | 361 | [Add your license information here] 362 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | models: 2 | - title: dolphin 3 | provider: ollama 4 | model: dolphin3-24b 5 | - title: lms_llama 6 | provider: lmstudio 7 | model: qwen2.5-coder-32b-instruct-mlx 8 | systemMessage: You are a helpful assistant that uses tools when appropriate. 9 | default: true 10 | - title: ollama_llama 11 | provider: ollama 12 | model: llama3.1 13 | - model: my-model 14 | title: custom-url-openai-compatible 15 | apiBase: http://whatever:8080/v1 16 | provider: openai 17 | - title: dolphin-r1 18 | provider: ollama 19 | model: dolphin3-r1 20 | temperature: 0.7 21 | top_k: 40 22 | - model: claude-3-7-sonnet-latest 23 | provider: anthropic 24 | apiKey: "****" 25 | title: claude 26 | temperature: 0.7 27 | top_k: 256 28 | top_p: 0.9 29 | max_tokens: 2048 30 | - model: gpt-4o 31 | title: gpt-4o 32 | provider: openai 33 | - model: o3-mini 34 | title: o3-mini 35 | systemMessage: You are an expert software developer. You give helpful and concise responses. 36 | contextLength: 128000 37 | maxCompletionTokens: 65536 38 | apiKey: "****" 39 | provider: openai 40 | temperature: 0.2 41 | top_p: 0.8 42 | - model: llama3.3:70b-instruct-q3_K_M 43 | title: llama3.3:70b 44 | client: http://mammoth:11434 45 | keep_alive_seconds: '240' 46 | provider: lmstudio 47 | - model: qwen2.5:72b-instruct-q3_K_S 48 | title: qwen2.5:72b 49 | client: http://mammoth:11434 50 | keep_alive_seconds: '240' 51 | provider: lmstudio 52 | mcpServers: 53 | dolphin-demo-database-sqlite: 54 | command: uvx 55 | args: 56 | - mcp-server-sqlite 57 | - --db-path 58 | - "~/.dolphin/dolphin.db" 59 | -------------------------------------------------------------------------------- /dolphin_mcp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import asyncio 6 | import aiofiles 7 | from termcolor import colored 8 | 9 | # Add the src directory to the Python path so we can import the modules 10 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) 11 | 12 | # Import from the package 13 | from dolphin_mcp.utils import parse_arguments 14 | from dolphin_mcp.client import run_interaction 15 | from dolphin_mcp.client import MCPAgent 16 | 17 | async def cli_main(): 18 | """ 19 | Main entry point for the CLI script. 20 | """ 21 | chosen_model_name, user_query, quiet_mode, chat_mode, config_path, log_messages_path = parse_arguments() 22 | if not chat_mode and not user_query: 23 | print("Usage: python dolphin_mcp.py [--model ] [--quiet] [--chat] [--config ] [--log-messages ] 'your question'") 24 | sys.exit(1) 25 | 26 | if not chat_mode: 27 | # Pass through to the package implementation 28 | final_text = await run_interaction( 29 | user_query=user_query, 30 | model_name=chosen_model_name, 31 | config_path=config_path, 32 | quiet_mode=quiet_mode, 33 | log_messages_path=log_messages_path 34 | ) 35 | 36 | print("\n" + final_text.strip() + "\n") 37 | else: 38 | # start a simple chat session 39 | agent = await MCPAgent.create( 40 | model_name=chosen_model_name, 41 | config_path=config_path, 42 | quiet_mode=quiet_mode, 43 | log_messages_path=log_messages_path) 44 | 45 | print(colored(f'MCPAgent using model: {agent.chosen_model["title"]}', 'cyan')) 46 | print(colored(f'Entering chat... Press ctrl-d to exit.', 'cyan')) 47 | 48 | async def read_lines(): 49 | async with aiofiles.open('/dev/stdin', mode='r') as f: 50 | print(colored("> ","yellow"), end="") 51 | sys.stdout.flush() 52 | async for line in f: 53 | print(colored(await agent.prompt(line.strip()),"green")) 54 | print(colored("> ","yellow"), end="") 55 | sys.stdout.flush() 56 | 57 | await read_lines() 58 | 59 | print("\nCleaning up...") 60 | await agent.cleanup() 61 | 62 | def main(): 63 | """ 64 | Entry point for the script. 65 | """ 66 | asyncio.run(cli_main()) 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # examples 2 | 3 | ## sqlite 4 | 5 | ```dolphin-mcp-cli --mcp-config examples/sqlite-mcp.json --model dolphin "Explore the database, and choose one random row - and write a story about it"``` 6 | 7 | ### Output 8 | 9 | ``` 10 | [OK] dolphin-demo-database-sqlite 11 | 12 | View result from list_tables from dolphin-demo-database-sqlite {} 13 | { 14 | "content": [ 15 | { 16 | "type": "text", 17 | "text": "[{'name': 'dolphin_species'}, {'name': 'evolutionary_relationships'}, {'name': 'table_name'}]" 18 | } 19 | ], 20 | "isError": false 21 | } 22 | 23 | View result from read_query from dolphin-demo-database-sqlite {"query": "SELECT * FROM dolphin_species ORDER BY RANDOM() LIMIT 1"} 24 | { 25 | "content": [ 26 | { 27 | "type": "text", 28 | "text": "[{'id': 3, 'common_name': 'Common Dolphin', 'scientific_name': 'Delphinus delphis', 'family': 'Delphinidae', 'habitat': 'Oceanic', 'average_length_meters': 2.3, 'average_weight_kg': 110.0, 'average_lifespan_years': 35, 'conservation_status': 'Least Concern', 'population_estimate': 'Unknown', 'evolutionary_ancestor': 'Kentriodontids', 'description': 'Known for their distinctive colorful pattern and hourglass pattern on their sides.'}]" 29 | } 30 | ], 31 | "isError": false 32 | } 33 | 34 | In the vast expanse of the ocean, where the sun kisses the water in a golden embrace, there swims a creature of grace and beauty. Meet the Common Dolphin, Delphinus delphis, a marvel of nature that has captivated human hearts for centuries. 35 | 36 | Our story begins with an individual dolphin, one whose life is as mysterious as the depths from which it emerges. This particular dolphin, measuring 2.3 meters in length and weighing around 110 kilograms, is a testament to the resilience and adaptability of its species. It belongs to the family Delphinidae, a group renowned for their intelligence and playful nature. 37 | 38 | The Common Dolphin's habitat is as boundless as the ocean itself, preferring the open waters where it can swim freely without constraint. Its average lifespan of 35 years speaks of a life filled with adventure and exploration, a journey through the highs and lows of the sea. 39 | 40 | But what sets this dolphin apart is not just its physical attributes or its habitat; it is the story of its evolutionary past. This dolphin's ancestors were once Kentriodontids, ancient creatures that roamed the oceans millions of years ago. Over time, they evolved into the sleek and sophisticated beings we know today. 41 | 42 | As our dolphin glides through the water, its sides adorned with a distinctive colorful pattern and an hourglass shape, it is a living testament to the power of evolution. Its conservation status as 'Least Concern' is a beacon of hope, a sign that despite the challenges faced by many marine species, this one thrives. 43 | 44 | Yet, there is still much we do not know about our dolphin friend. The population estimate remains a mystery, a secret hidden beneath the waves. But what we do know is enough to inspire awe and wonder. 45 | 46 | In the heart of the ocean, where the unknown awaits around every corner, our Common Dolphin swims on. Its story is one of survival, adaptation, and beauty, a reminder of the magic that lies just beyond our reach in the depths of the sea. 47 | 48 | ``` 49 | 50 | ## filesystem-fetch 51 | 52 | ```dolphin-mcp-cli --mcp-config examples/filesystem-fetch-mcp.json --model gpt-4o "Read ./examples/stocklist.txt and fetch https://finance.yahoo.com/topic/stock-market-news/. If there is positive news about any of the stocks in the list, advise me to buy that stock. if there is negative news about any of the stocks in the list, advise me to sell that stock."``` 53 | 54 | ### Output 55 | 56 | ``` 57 | [OK] filesystem 58 | [OK] fetch 59 | 60 | View result from read_file from filesystem {"path": "./examples/stocklist.txt"} 61 | { 62 | "content": [ 63 | { 64 | "type": "text", 65 | "text": "AAPL\nMSFT\nNVDA\nAMZN\nGOOGL\nTSLA\nJPM\nJNJ\nXOM\nDIS" 66 | } 67 | ] 68 | } 69 | 70 | View result from fetch from fetch {"url": "https://finance.yahoo.com/topic/stock-market-news/", "max_length": 10000} 71 | { 72 | "content": [ 73 | { 74 | "type": "text", 75 | "text": "Contents of https://finance.yahoo.com/topic/stock-market-news/:\n* [![Most baby boomers can\u2019t afford assisted living and are weighing on the housing market by staying in their homes, \u2018Oracle of Wall Street\u2019 says](https://s.yimg.com/uu/api/res/1.2/ivl4tU10JsMMXXGZpY5Edw--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/fortune_175/038149f7d7a61eab6b1ffba41dcf758c.cf.webp)](https://finance.yahoo.com/news/most-baby-boomers-t-afford-220048092.html \"Most baby boomers can\u2019t afford assisted living and are weighing on the housing market by staying in their homes, \u2018Oracle of Wall Street\u2019 says\")\n\n [### Most baby boomers can\u2019t afford assisted living and are weighing on the housing market by staying in their homes, \u2018Oracle of Wall Street\u2019 says\n\n \"This is one of the problems with the housing inventory. They're staying in their houses longer because they can't afford to move out.\"](https://finance.yahoo.com/news/most-baby-boomers-t-afford-220048092.html \"Most baby boomers can\u2019t afford assisted living and are weighing on the housing market by staying in their homes, \u2018Oracle of Wall Street\u2019 says\")\n\n Fortune 5 hours ago\n* [![Digital marketing used to be about clicks, but the rise of ChatGPT means it\u2019s \u2018now all about winning the mentions\u2019](https://s.yimg.com/uu/api/res/1.2/anetyzotfpwoXbd5aEwWOQ--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/fortune_175/3215f088034585320facdda4cd4781b5.cf.webp)](https://finance.yahoo.com/news/digital-marketing-used-clicks-rise-192813332.html \"Digital marketing used to be about clicks, but the rise of ChatGPT means it\u2019s \u2018now all about winning the mentions\u2019\")\n\n [### Digital marketing used to be about clicks, but the rise of ChatGPT means it\u2019s \u2018now all about winning the mentions\u2019\n\n Brand credibility is now a crucial aspect of marketing campaigns, and companies must couple that with storytelling, experts say.](https://finance.yahoo.com/news/digital-marketing-used-clicks-rise-192813332.html \"Digital marketing used to be about clicks, but the rise of ChatGPT means it\u2019s \u2018now all about winning the mentions\u2019\")\n\n Fortune 8 hours ago\n* [![Wall Street plays long game as deals go private](https://s.yimg.com/uu/api/res/1.2/vJQcQkoYRNIOX.AqCSn5og--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://s.yimg.com/os/creatr-uploaded-images/2025-05/800d4880-2dda-11f0-abfe-fd1ab4279da0.cf.webp)](https://finance.yahoo.com/news/wall-street-plays-long-game-190000469.html \"Wall Street plays long game as deals go private\")\n\n [### Wall Street plays long game as deals go private\n\n (Bloomberg) -- A KKR & Co. debt sale shows how far Wall Street is willing to go to keep leveraged underwriting business from slipping away to private credit after periods of turmoil.Most Read from BloombergAs Trump Reshapes Housing Policy, Renters Face Rollback of RightsIs Trump\u2019s Plan to Reopen the Notorious Alcatraz Prison Realistic?What\u2019s Behind the Rise in Serious Injuries on New York City\u2019s Streets?A New Central Park Amenity, Tailored to Its East Harlem NeighborsNYC Warns of 17% Drop in For](https://finance.yahoo.com/news/wall-street-plays-long-game-190000469.html \"Wall Street plays long game as deals go private\")\n\n Bloomberg 8 hours ago\n\n [AAPL](/quote/AAPL/ \"AAPL\") [GM](/quote/GM/ \"GM\")\n* [![A midsize city in upstate New York is the country\u2019s toughest housing market this spring](https://s.yimg.com/uu/api/res/1.2/XLgKcrtPz1yIun2t0nJ6ew--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://s.yimg.com/os/creatr-images/2020-06/17612370-a855-11ea-9fde-d414f241d6d0.cf.webp)](https://finance.yahoo.com/news/a-midsize-city-in-upstate-new-york-is-the-countrys-toughest-housing-market-this-spring-143308210.html \"A midsize city in upstate New York is the country\u2019s toughest housing market this spring\")\n\n [### A midsize city in upstate New York is the country\u2019s toughest housing market this spring\n\n The median home in Rochester, N.Y. sells for just $225,000, and competition to buy is steeper than in San Francisco and Boston.](https://finance.yahoo.com/news/a-midsize-city-in-upstate-new-york-is-the-countrys-toughest-housing-market-this-spring-143308210.html \"A midsize city in upstate New York is the country\u2019s toughest housing market this spring\")\n\n Yahoo Finance 13 hours ago\n* [![For Exhausted Stock Market Pros the Choice Is Buy or Stay Home](https://s.yimg.com/uu/api/res/1.2/M2ORaeWGrV9FiUrQT8mQ1Q--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/bloomberg_holding_pen_162/5a26f0563703088194cdc654067a177c.cf.webp)](https://finance.yahoo.com/news/exhausted-stock-market-pros-choice-120007868.html \"For Exhausted Stock Market Pros the Choice Is Buy or Stay Home\")\n\n [### For Exhausted Stock Market Pros the Choice Is Buy or Stay Home\n\n (Bloomberg) -- The stock market\u2019s stunning rebound over the last month has largely been driven by Main Street investors buying the dip in everything in sight while professional money managers ditched US stocks, spooked by mounting fears of slowing economic growth and trade war disruptions.Most Read from BloombergAs Trump Reshapes Housing Policy, Renters Face Rollback of RightsIs Trump\u2019s Plan to Reopen the Notorious Alcatraz Prison Realistic?What\u2019s Behind the Rise in Serious Injuries on New York](https://finance.yahoo.com/news/exhausted-stock-market-pros-choice-120007868.html \"For Exhausted Stock Market Pros the Choice Is Buy or Stay Home\")\n\n Bloomberg 15 hours ago\n\n [NVDA](/quote/NVDA/ \"NVDA\") [JPM](/quote/JPM/ \"JPM\")\n* [![The Graduating Class of 2025 is Entering an Uncertain Job Market](https://s.yimg.com/uu/api/res/1.2/5lk1RnfIE7ChAECeg9FS4A--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/investopedia_245/d755d1738727234ebac204193e26c017.cf.webp)](https://finance.yahoo.com/news/graduating-class-2025-entering-uncertain-104600208.html \"The Graduating Class of 2025 is Entering an Uncertain Job Market\")\n\n [### The Graduating Class of 2025 is Entering an Uncertain Job Market\n\n As an estimated 2 million college graduates hope to find a job this spring, they enter a cooling job market in which employers are bracing for the impact of President Donald Trump's tariffs.](https://finance.yahoo.com/news/graduating-class-2025-entering-uncertain-104600208.html \"The Graduating Class of 2025 is Entering an Uncertain Job Market\")\n\n Investopedia 17 hours ago\n* [![Best money market account rates today, May 10, 2025 (best account provides 4.41% APY)](https://s.yimg.com/uu/api/res/1.2/GJKwz40mSpK0EHAJt50HHA--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://s.yimg.com/os/creatr-uploaded-images/2025-04/678943f0-1cdb-11f0-bfb7-b3f563b0ea90.cf.webp)](https://finance.yahoo.com/personal-finance/banking/article/best-money-market-account-rates-today-saturday-may-10-2025-100027028.html \"Best money market account rates today, May 10, 2025 (best account provides 4.41% APY)\")\n\n [### Best money market account rates today, May 10, 2025 (best account provides 4.41% APY)\n\n If you\u2019re searching for today\u2019s best money market account rates, we\u2019ve narrowed down some of the top offers. Learn more about money market account rates today.](https://finance.yahoo.com/personal-finance/banking/article/best-money-market-account-rates-today-saturday-may-10-2025-100027028.html \"Best money market account rates today, May 10, 2025 (best account provides 4.41% APY)\")\n\n Yahoo Personal Finance 17 hours ago\n* [![Trump is about to drop a multi-trillion bomb on the stock market](https://s.yimg.com/uu/api/res/1.2/lpyIXUVboedRXtbjDbUQzw--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/the_telegraph_258/0c9f43024792b12fa2baddc0067ea7d2.cf.webp)](https://finance.yahoo.com/news/trump-big-beautiful-bill-threatens-070000728.html \"Trump is about to drop a multi-trillion bomb on the stock market\")\n\n [### Trump is about to drop a multi-trillion bomb on the stock market\n\n Donald Trump has branded it the \u201cbig beautiful bill\u201d that will save millions of jobs and boost Americans\u2019 take-home pay by up to $5,000 (\u00a33,700) a year.](https://finance.yahoo.com/news/trump-big-beautiful-bill-threatens-070000728.html \"Trump is about to drop a multi-trillion bomb on the stock market\")\n\n The Telegraph 20 hours ago\n* [![Why BofA says more trade deals can't keep the stock market rally alive](https://s.yimg.com/uu/api/res/1.2/NfHh3CE3DVMXgxlFGyk17A--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/business_insider_articles_888/362d8df0e2705d4a4c94696e71e3c528.cf.webp)](https://finance.yahoo.com/news/why-bofa-says-more-trade-001310446.html \"Why BofA says more trade deals can't keep the stock market rally alive\")\n\n [### Why BofA says more trade deals can't keep the stock market rally alive\n\n The S&P 500 has rallied sharply since April lows, but investors should be prepared for the gains to fade as more trade deals are announced, BofA said.](https://finance.yahoo.com/news/why-bofa-says-more-trade-001310446.html \"Why BofA says more trade deals can't keep the stock market rally alive\")\n\n Business Insider yesterday\n* [![From a recession to the impact of AI, 4 predictions from Wall Street's 'Dr. Doom'](https://s.yimg.com/uu/api/res/1.2/jxj_4JI_o7FGy7HL965edQ--~B/Zmk9c3RyaW07aD0xMjY7cT04MDt3PTE2ODthcHBpZD15dGFjaHlvbg--/https://media.zenfs.com/en/business_insider_articles_888/ac493b3a8a7fca88c3441b85cdff65c4.cf.webp)](https://finance.yahoo.com/news/recession-impact-ai-4-predictions-225612935.html \"From a recession to the impact of AI, 4 predictions from Wall Street's 'Dr. Doom'\")\n\n [### From a recession to the impact of AI, 4 predictions from Wall Street's 'Dr. Doom'\n\n The US could enter a mild recession by the end of 2025, according to Wall Street's \"Dr. Doom\" economist, Nouriel Roubini.](https://finance.yahoo.com/news/recession-impact-ai-4-predictions-225612935.html \"From a recession to the impact of AI, 4 predictions from Wall Street's 'Dr. Doom'\")\n\n Business Insider yesterday\n\nContent truncated. Call the fetch tool with a start_index of 10000 to get more content." 76 | } 77 | ], 78 | "isError": false 79 | } 80 | 81 | From the content available, there are mentions of some of the stocks in your list: 82 | 83 | 1. **AAPL (Apple Inc.)** - The article mentions AAPL in the context of Wall Street playing a long game as deals go private. No specific positive or negative sentiment is mentioned. 84 | 85 | 2. **NVDA (NVIDIA Corporation)** and **JPM (JPMorgan Chase & Co.)** - Both are mentioned in the context of the stock market's stunning rebound driven by Main Street investors. There is a sense of positivity here about the market, which could imply a positive sentiment towards these stocks. 86 | 87 | Based on the articles retrieved, there isn't any explicit negative news about stocks from your list. However, there is a positive implication for NVDA and JPM as the stock market is rebounding due to increased activity from Main Street investors and implied positivity about specific stocks. Therefore: 88 | 89 | - **Advice**: Consider buying NVDA and JPM due to the positive sentiment in the coverage related to these stocks. 90 | 91 | If you want more detailed news, let me know so I can fetch additional content. 92 | ``` 93 | 94 | 95 | -------------------------------------------------------------------------------- /examples/filesystem-fetch-mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "filesystem": { 4 | "command": "npx", 5 | "args": [ 6 | "-y", 7 | "@modelcontextprotocol/server-filesystem", 8 | "./" 9 | ] 10 | }, 11 | "fetch": { 12 | "command": "uvx", 13 | "args": ["mcp-server-fetch"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/sqlite-mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "dolphin-demo-database-sqlite": { 4 | "command": "uvx", 5 | "args": [ 6 | "mcp-server-sqlite", 7 | "--db-path", 8 | "~/.dolphin/dolphin.db" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/stocklist.txt: -------------------------------------------------------------------------------- 1 | AAPL 2 | MSFT 3 | NVDA 4 | AMZN 5 | GOOGL 6 | TSLA 7 | JPM 8 | JNJ 9 | XOM 10 | DIS -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dolphin-mcp" 7 | version = "0.1.3" 8 | description = "A flexible Python client for interacting with Model Context Protocol (MCP) servers" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Dolphin MCP Team"} 14 | ] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ] 20 | dependencies = [ 21 | "openai", 22 | "mcp[cli]", 23 | "python-dotenv", 24 | "anthropic", 25 | "ollama", 26 | "jsonschema", 27 | "PyYAML", 28 | ] 29 | 30 | [project.optional-dependencies] 31 | dev = [ 32 | "pytest", 33 | "pytest-asyncio", 34 | "pytest-mock", 35 | "uv", 36 | ] 37 | demo = [ 38 | "mcp-server-sqlite", 39 | ] 40 | 41 | [project.scripts] 42 | dolphin-mcp-cli = "dolphin_mcp.cli:main" 43 | 44 | [tool.setuptools.packages.find] 45 | where = ["src"] 46 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | openai 2 | mcp[cli] 3 | python-dotenv 4 | pytest 5 | pytest-asyncio 6 | pytest-mock 7 | uv 8 | mcp-server-sqlite 9 | jsonschema 10 | anthropic 11 | ollama 12 | lmstudio 13 | termcolor 14 | aiofiles 15 | pyyaml 16 | aiohttp -------------------------------------------------------------------------------- /setup_db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Setup script to create a test SQLite database with dolphin species information. 4 | This database will be stored at ~/.dolphin/dolphin.db and includes information about 5 | different dolphin species, their characteristics, and evolutionary relationships. 6 | """ 7 | 8 | import os 9 | import sqlite3 10 | import pathlib 11 | from pathlib import Path 12 | 13 | def create_dolphin_database(): 14 | """Create a SQLite database with dolphin species information.""" 15 | # Create directory if it doesn't exist using pathlib for cross-platform compatibility 16 | db_dir = Path.home() / ".dolphin" 17 | db_dir.mkdir(parents=True, exist_ok=True) 18 | 19 | # Database path 20 | db_path = db_dir / "dolphin.db" 21 | 22 | # Check if database already exists 23 | db_exists = db_path.exists() 24 | 25 | # Connect to database (creates it if it doesn't exist) 26 | conn = sqlite3.connect(str(db_path)) 27 | cursor = conn.cursor() 28 | 29 | # Create tables if they don't exist 30 | cursor.execute(''' 31 | CREATE TABLE IF NOT EXISTS dolphin_species ( 32 | id INTEGER PRIMARY KEY, 33 | common_name TEXT NOT NULL, 34 | scientific_name TEXT NOT NULL, 35 | family TEXT NOT NULL, 36 | habitat TEXT NOT NULL, 37 | average_length_meters REAL, 38 | average_weight_kg REAL, 39 | average_lifespan_years INTEGER, 40 | conservation_status TEXT, 41 | population_estimate TEXT, 42 | evolutionary_ancestor TEXT, 43 | description TEXT 44 | ) 45 | ''') 46 | 47 | # Create a separate table for evolutionary relationships 48 | cursor.execute(''' 49 | CREATE TABLE IF NOT EXISTS evolutionary_relationships ( 50 | id INTEGER PRIMARY KEY, 51 | species_id INTEGER, 52 | related_species_id INTEGER, 53 | relationship_type TEXT NOT NULL, 54 | divergence_mya REAL, 55 | FOREIGN KEY (species_id) REFERENCES dolphin_species(id), 56 | FOREIGN KEY (related_species_id) REFERENCES dolphin_species(id) 57 | ) 58 | ''') 59 | 60 | # Check if we already have data 61 | cursor.execute("SELECT COUNT(*) FROM dolphin_species") 62 | count = cursor.fetchone()[0] 63 | 64 | # Only insert data if the table is empty 65 | if count == 0: 66 | # Insert dolphin species data 67 | dolphin_species = [ 68 | (1, "Common Bottlenose Dolphin", "Tursiops truncatus", "Delphinidae", "Coastal & Oceanic", 2.5, 300, 45, "Least Concern", "600,000+", "Kentriodontids", "One of the most well-known dolphin species, highly intelligent with complex social structures."), 69 | (2, "Indo-Pacific Bottlenose Dolphin", "Tursiops aduncus", "Delphinidae", "Coastal", 2.6, 230, 40, "Near Threatened", "Unknown", "Kentriodontids", "Slightly smaller than common bottlenose dolphins with a more slender body."), 70 | (3, "Common Dolphin", "Delphinus delphis", "Delphinidae", "Oceanic", 2.3, 110, 35, "Least Concern", "Unknown", "Kentriodontids", "Known for their distinctive colorful pattern and hourglass pattern on their sides."), 71 | (4, "Spinner Dolphin", "Stenella longirostris", "Delphinidae", "Oceanic", 2.0, 80, 25, "Least Concern", "Unknown", "Kentriodontids", "Famous for their acrobatic displays, spinning multiple times along their longitudinal axis."), 72 | (5, "Pantropical Spotted Dolphin", "Stenella attenuata", "Delphinidae", "Oceanic", 2.1, 120, 40, "Least Concern", "3,000,000+", "Kentriodontids", "Born without spots and develop them as they age."), 73 | (6, "Atlantic Spotted Dolphin", "Stenella frontalis", "Delphinidae", "Coastal & Oceanic", 2.3, 140, 35, "Least Concern", "Unknown", "Kentriodontids", "Closely related to pantropical spotted dolphins but with different spotting patterns."), 74 | (7, "Striped Dolphin", "Stenella coeruleoalba", "Delphinidae", "Oceanic", 2.4, 150, 40, "Least Concern", "2,000,000+", "Kentriodontids", "Distinctive blue and white stripes along their sides."), 75 | (8, "Rough-toothed Dolphin", "Steno bredanensis", "Delphinidae", "Oceanic", 2.5, 150, 35, "Least Concern", "Unknown", "Kentriodontids", "Distinctive conical head without a clear melon-forehead division."), 76 | (9, "Risso's Dolphin", "Grampus griseus", "Delphinidae", "Oceanic", 3.8, 500, 35, "Least Concern", "Unknown", "Kentriodontids", "Distinctive appearance with extensive scarring on adults."), 77 | (10, "Fraser's Dolphin", "Lagenodelphis hosei", "Delphinidae", "Oceanic", 2.5, 210, 25, "Least Concern", "Unknown", "Kentriodontids", "Stocky body with small appendages and distinctive lateral stripe."), 78 | (11, "Hector's Dolphin", "Cephalorhynchus hectori", "Delphinidae", "Coastal", 1.4, 50, 20, "Endangered", "<7,000", "Kentriodontids", "One of the smallest dolphin species and endemic to New Zealand."), 79 | (12, "Maui Dolphin", "Cephalorhynchus hectori maui", "Delphinidae", "Coastal", 1.4, 50, 20, "Critically Endangered", "<50", "Kentriodontids", "Subspecies of Hector's dolphin, one of the rarest and most endangered dolphins."), 80 | (13, "Amazon River Dolphin", "Inia geoffrensis", "Iniidae", "Freshwater", 2.5, 185, 30, "Endangered", "Unknown", "Platanistoidea", "Also known as the pink river dolphin, largest freshwater dolphin species."), 81 | (14, "Ganges River Dolphin", "Platanista gangetica", "Platanistidae", "Freshwater", 2.2, 85, 30, "Endangered", "<2,000", "Platanistoidea", "Nearly blind, uses echolocation to navigate muddy river waters."), 82 | (15, "Irrawaddy Dolphin", "Orcaella brevirostris", "Delphinidae", "Coastal & Freshwater", 2.3, 130, 30, "Endangered", "<7,000", "Kentriodontids", "Found in coastal areas and three rivers in Southeast Asia."), 83 | (16, "Orca (Killer Whale)", "Orcinus orca", "Delphinidae", "Oceanic & Coastal", 7.0, 5600, 50, "Data Deficient", "50,000+", "Kentriodontids", "Largest dolphin species, apex predator with complex social structures."), 84 | (17, "False Killer Whale", "Pseudorca crassidens", "Delphinidae", "Oceanic", 5.5, 1500, 60, "Near Threatened", "Unknown", "Kentriodontids", "Despite the name, more closely related to dolphins like Risso's and pilot whales."), 85 | (18, "Long-finned Pilot Whale", "Globicephala melas", "Delphinidae", "Oceanic", 6.5, 2500, 45, "Least Concern", "200,000+", "Kentriodontids", "Actually a large dolphin, forms strong social bonds and large pods."), 86 | (19, "Short-finned Pilot Whale", "Globicephala macrorhynchus", "Delphinidae", "Oceanic", 5.5, 2200, 45, "Least Concern", "Unknown", "Kentriodontids", "Similar to long-finned pilot whales but with genetic and morphological differences."), 87 | (20, "Commerson's Dolphin", "Cephalorhynchus commersonii", "Delphinidae", "Coastal", 1.5, 60, 18, "Least Concern", "Unknown", "Kentriodontids", "Distinctive black and white patterning, one of the smallest dolphin species.") 88 | ] 89 | 90 | cursor.executemany(''' 91 | INSERT INTO dolphin_species ( 92 | id, common_name, scientific_name, family, habitat, 93 | average_length_meters, average_weight_kg, average_lifespan_years, 94 | conservation_status, population_estimate, evolutionary_ancestor, description 95 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 96 | ''', dolphin_species) 97 | 98 | # Insert evolutionary relationships data 99 | # This is a simplified representation of some key relationships 100 | relationships = [ 101 | (1, 1, 2, "Sister Species", 2.5), # Bottlenose dolphin species split 102 | (2, 1, 3, "Same Family", 10), # Bottlenose and Common dolphins 103 | (3, 3, 4, "Same Genus", 5), # Within Stenella genus 104 | (4, 4, 5, "Sister Species", 3), # Spinner and Spotted dolphins 105 | (5, 5, 6, "Sister Species", 2), # Spotted dolphin species 106 | (6, 7, 5, "Same Genus", 4), # Striped and Spotted dolphins 107 | (7, 16, 17, "Evolutionary Cousins", 15), # Orca and False Killer Whale 108 | (8, 18, 19, "Sister Species", 3.5), # Pilot whale species 109 | (9, 16, 18, "Same Family", 12), # Orca and Pilot whales 110 | (10, 11, 12, "Subspecies", 0.8), # Hector's and Maui dolphins 111 | (11, 11, 20, "Same Genus", 4), # Hector's and Commerson's dolphins 112 | (12, 13, 14, "Different Families", 25), # River dolphin species 113 | (13, 1, 13, "Distant Relatives", 35), # Oceanic and river dolphins split 114 | (14, 15, 16, "Same Family", 18), # Irrawaddy and Orca 115 | (15, 9, 18, "Evolutionary Branch", 14) # Risso's and Pilot whales 116 | ] 117 | 118 | cursor.executemany(''' 119 | INSERT INTO evolutionary_relationships ( 120 | id, species_id, related_species_id, relationship_type, divergence_mya 121 | ) VALUES (?, ?, ?, ?, ?) 122 | ''', relationships) 123 | 124 | # Commit changes and close connection 125 | conn.commit() 126 | conn.close() 127 | 128 | # Report status 129 | if db_exists: 130 | print(f"Database already existed at {db_path}") 131 | if count > 0: 132 | print(f"Database contains {count} dolphin species records") 133 | else: 134 | print(f"Added {len(dolphin_species)} dolphin species to the database") 135 | else: 136 | print(f"Created new database at {db_path}") 137 | print(f"Added {len(dolphin_species)} dolphin species to the database") 138 | print(f"Added {len(relationships)} evolutionary relationships to the database") 139 | 140 | print("\nDatabase Schema:") 141 | print("- Table: dolphin_species") 142 | print(" Columns: id, common_name, scientific_name, family, habitat, average_length_meters,") 143 | print(" average_weight_kg, average_lifespan_years, conservation_status,") 144 | print(" population_estimate, evolutionary_ancestor, description") 145 | print("- Table: evolutionary_relationships") 146 | print(" Columns: id, species_id, related_species_id, relationship_type, divergence_mya") 147 | 148 | return db_path 149 | 150 | if __name__ == "__main__": 151 | db_path = create_dolphin_database() 152 | 153 | # Show sample queries that can be run against this database 154 | print("\nSample queries you can run:") 155 | print("1. List all dolphin species:") 156 | print(" SELECT common_name, scientific_name FROM dolphin_species") 157 | print("2. Find endangered species:") 158 | print(" SELECT common_name FROM dolphin_species WHERE conservation_status LIKE '%Endangered%'") 159 | print("3. Find evolutionary relationships:") 160 | print(" SELECT d1.common_name, d2.common_name, r.relationship_type, r.divergence_mya") 161 | print(" FROM evolutionary_relationships r") 162 | print(" JOIN dolphin_species d1 ON r.species_id = d1.id") 163 | print(" JOIN dolphin_species d2 ON r.related_species_id = d2.id") -------------------------------------------------------------------------------- /src/dolphin_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dolphin MCP - A flexible Python client for interacting with Model Context Protocol (MCP) servers. 3 | """ 4 | 5 | from .client import MCPClient, run_interaction 6 | 7 | __version__ = "0.1.3" 8 | __all__ = ["MCPClient", "run_interaction"] 9 | -------------------------------------------------------------------------------- /src/dolphin_mcp/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command-line interface for Dolphin MCP. 3 | """ 4 | 5 | import asyncio 6 | import sys 7 | import logging 8 | from .utils import parse_arguments, load_config_from_file # Added load_config_from_file 9 | from .client import run_interaction, MCPAgent # Added MCPAgent 10 | 11 | async def main(): # Changed to async def 12 | """ 13 | Main entry point for the CLI. 14 | """ 15 | # Configure logging 16 | logging.basicConfig( 17 | level=logging.DEBUG, # Set logging level to DEBUG 18 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 19 | stream=sys.stderr # Log to stderr 20 | ) 21 | logger = logging.getLogger("dolphin_mcp") # Get logger instance after basicConfig 22 | logger.debug("Logging configured at DEBUG level.") 23 | 24 | # Check for help flag first 25 | if "--help" in sys.argv or "-h" in sys.argv: 26 | print("Usage: dolphin-mcp-cli [--model ] [--quiet] [--interactive | -i] [--config ] [--mcp-config ] [--log-messages ] [--debug] ['your question']") 27 | print("\nOptions:") 28 | print(" --model Specify the model to use") 29 | print(" --quiet Suppress intermediate output (except errors)") 30 | print(" --interactive, -i Enable interactive chat mode") 31 | print(" --config Specify a custom config file for providers (default: config.yml)") 32 | print(" --mcp-config Specify a custom config file for MCP servers (default: examples/sqlite-mcp.json)") 33 | print(" --log-messages Log all LLM interactions to a JSONL file") 34 | # Consider adding a --debug flag later if needed, but basicConfig sets it for now 35 | print(" --help, -h Show this help message") 36 | sys.exit(0) 37 | 38 | chosen_model_name, user_query, quiet_mode, chat_mode, interactive_mode, config_path, mcp_config_path, log_messages_path = parse_arguments() 39 | 40 | if interactive_mode: 41 | logger.debug("Interactive mode enabled.") 42 | provider_config = await load_config_from_file(config_path) 43 | agent = await MCPAgent.create( 44 | model_name=chosen_model_name, 45 | provider_config=provider_config, 46 | mcp_server_config_path=mcp_config_path, 47 | quiet_mode=quiet_mode, # Pass quiet_mode 48 | log_messages_path=log_messages_path, 49 | stream=True # Interactive mode implies streaming 50 | ) 51 | loop = asyncio.get_event_loop() 52 | try: 53 | while True: 54 | current_query = "" 55 | if user_query: # Use initial query first if provided 56 | current_query = user_query 57 | print(f"> {current_query}") # Simulate user typing the initial query 58 | user_query = None # Clear after use 59 | else: 60 | try: 61 | user_input = await loop.run_in_executor(None, input, "> ") 62 | except EOFError: # Handle Ctrl+D 63 | print("\nExiting interactive mode.") 64 | break 65 | if user_input.lower() in ["exit", "quit"]: 66 | print("Exiting interactive mode.") 67 | break 68 | if not user_input: 69 | continue 70 | current_query = user_input 71 | 72 | if not current_query.strip(): # If after all that, query is empty, continue 73 | continue 74 | 75 | if not quiet_mode: 76 | print("AI: ", end="", flush=True) 77 | 78 | response_generator = await agent.prompt(current_query) 79 | full_response = "" 80 | async for chunk in response_generator: 81 | print(chunk, end="", flush=True) 82 | full_response += chunk 83 | print() # Add a newline after the full response 84 | 85 | # In a real chat, we might add full_response to a history 86 | # For now, each input is a new prompt in the same session 87 | 88 | finally: 89 | await agent.cleanup() 90 | logger.debug("Agent cleaned up.") 91 | 92 | else: # Non-interactive mode 93 | if not user_query and not chat_mode: # Original condition for non-interactive query requirement 94 | # If not in interactive mode, and no query is provided, and not in single-shot chat mode, show usage and exit. 95 | print("Usage: dolphin-mcp-cli [--model ] [--quiet] [--config ] [--mcp-config ] [--log-messages ] 'your question'", file=sys.stderr) 96 | sys.exit(1) 97 | 98 | # We do not pass a config object; we pass provider_config_path and mcp_server_config_path 99 | final_text = await run_interaction( # Changed to await 100 | user_query=user_query, 101 | model_name=chosen_model_name, 102 | provider_config_path=config_path, 103 | mcp_server_config_path=mcp_config_path, 104 | quiet_mode=quiet_mode, 105 | # chat_mode from args determines if single-shot interaction should stream 106 | stream=chat_mode, 107 | log_messages_path=log_messages_path 108 | ) 109 | 110 | if not quiet_mode or final_text: 111 | print("\n" + final_text.strip() + "\n") 112 | 113 | if __name__ == "__main__": 114 | asyncio.run(main()) # Changed to asyncio.run(main()) 115 | -------------------------------------------------------------------------------- /src/dolphin_mcp/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core client functionality for Dolphin MCP. 3 | """ 4 | import traceback 5 | import os 6 | import sys 7 | import json 8 | import asyncio 9 | import logging 10 | from typing import Any, Dict, List, Optional, Union, AsyncGenerator 11 | 12 | from mcp.client.sse import sse_client 13 | from mcp import ClientSession 14 | 15 | from .utils import load_config_from_file # Renamed import 16 | from .providers.openai import generate_with_openai 17 | from .providers.msazureopenai import generate_with_msazure_openai 18 | from .providers.anthropic import generate_with_anthropic 19 | from .providers.ollama import generate_with_ollama 20 | from .providers.lmstudio import generate_with_lmstudio 21 | 22 | logger = logging.getLogger("dolphin_mcp") 23 | 24 | 25 | class SSEMCPClient: 26 | """Implementation for a SSE-based MCP server.""" 27 | 28 | def __init__(self, server_name: str, url: str): 29 | self.server_name = server_name 30 | self.url = url 31 | self.tools = [] 32 | self._streams_context = None 33 | self._session_context = None 34 | self.session = None 35 | 36 | async def start(self): 37 | try: 38 | self._streams_context = sse_client(url=self.url) 39 | streams = await self._streams_context.__aenter__() 40 | 41 | self._session_context = ClientSession(*streams) 42 | self.session = await self._session_context.__aenter__() 43 | 44 | # Initialize 45 | await self.session.initialize() 46 | return True 47 | except Exception as e: 48 | logger.error(f"Server {self.server_name}: SSE connection error: {str(e)}") 49 | return False 50 | 51 | async def list_tools(self): 52 | if not self.session: 53 | return [] 54 | try: 55 | response = await self.session.list_tools() 56 | # 将 pydantic 模型转换为字典格式 57 | self.tools = [ 58 | { 59 | "name": tool.name, 60 | "description": tool.description, 61 | # "inputSchema": tool.inputSchema.model_dump() if tool.inputSchema else None 62 | "inputSchema": tool.inputSchema 63 | } 64 | for tool in response.tools 65 | ] 66 | return self.tools 67 | except Exception as e: 68 | logger.error(f"Server {self.server_name}: List tools error: {str(e)}") 69 | return [] 70 | 71 | async def call_tool(self, tool_name: str, arguments: dict): 72 | if not self.session: 73 | return {"error": "Not connected"} 74 | try: 75 | response = await self.session.call_tool(tool_name, arguments) 76 | # 将 pydantic 模型转换为字典格式 77 | return response.model_dump() if hasattr(response, 'model_dump') else response 78 | except Exception as e: 79 | logger.error(f"Server {self.server_name}: Tool call error: {str(e)}") 80 | return {"error": str(e)} 81 | 82 | async def stop(self): 83 | if self.session: 84 | await self._session_context.__aexit__(None, None, None) 85 | if self._streams_context: 86 | await self._streams_context.__aexit__(None, None, None) 87 | 88 | 89 | class MCPClient: 90 | """Implementation for a single MCP server.""" 91 | def __init__(self, server_name, command, args=None, env=None, cwd=None): 92 | self.server_name = server_name 93 | self.command = command 94 | self.args = args or [] 95 | self.env = env 96 | self.process = None 97 | self.tools = [] 98 | self.request_id = 0 99 | self.protocol_version = "2024-11-05" 100 | self.receive_task = None 101 | self.responses = {} 102 | self.server_capabilities = {} 103 | self._shutdown = False 104 | self._cleanup_lock = asyncio.Lock() 105 | self.cwd = cwd 106 | 107 | async def _receive_loop(self): 108 | if not self.process or self.process.stdout.at_eof(): 109 | return 110 | try: 111 | while not self.process.stdout.at_eof(): 112 | line = await self.process.stdout.readline() 113 | if not line: 114 | break 115 | try: 116 | message = json.loads(line.decode().strip()) 117 | self._process_message(message) 118 | except json.JSONDecodeError: 119 | pass 120 | except Exception: 121 | pass 122 | 123 | def _process_message(self, message: dict): 124 | if "jsonrpc" in message and "id" in message: 125 | if "result" in message or "error" in message: 126 | self.responses[message["id"]] = message 127 | else: 128 | # request from server, not implemented 129 | resp = { 130 | "jsonrpc": "2.0", 131 | "id": message["id"], 132 | "error": { 133 | "code": -32601, 134 | "message": f"Method {message.get('method')} not implemented in client" 135 | } 136 | } 137 | asyncio.create_task(self._send_message(resp)) 138 | elif "jsonrpc" in message and "method" in message and "id" not in message: 139 | # notification from server 140 | pass 141 | 142 | async def start(self): 143 | expanded_args = [] 144 | for a in self.args: 145 | if isinstance(a, str) and "~" in a: 146 | expanded_args.append(os.path.expanduser(a)) 147 | else: 148 | expanded_args.append(a) 149 | 150 | env_vars = os.environ.copy() 151 | if self.env: 152 | env_vars.update(self.env) 153 | 154 | try: 155 | self.process = await asyncio.create_subprocess_exec( 156 | self.command, 157 | *expanded_args, 158 | stdin=asyncio.subprocess.PIPE, 159 | stdout=asyncio.subprocess.PIPE, 160 | stderr=asyncio.subprocess.PIPE, 161 | env=env_vars, 162 | cwd=self.cwd 163 | ) 164 | self.receive_task = asyncio.create_task(self._receive_loop()) 165 | return await self._perform_initialize() 166 | except Exception: 167 | return False 168 | 169 | async def _perform_initialize(self): 170 | self.request_id += 1 171 | req_id = self.request_id 172 | req = { 173 | "jsonrpc": "2.0", 174 | "id": req_id, 175 | "method": "initialize", 176 | "params": { 177 | "protocolVersion": self.protocol_version, 178 | "capabilities": {"sampling": {}}, 179 | "clientInfo": { 180 | "name": "DolphinMCPClient", 181 | "version": "1.0.0" 182 | } 183 | } 184 | } 185 | await self._send_message(req) 186 | 187 | start = asyncio.get_event_loop().time() 188 | timeout = 10 # Increased timeout to 10 seconds 189 | while asyncio.get_event_loop().time() - start < timeout: 190 | if req_id in self.responses: 191 | resp = self.responses[req_id] 192 | del self.responses[req_id] 193 | if "error" in resp: 194 | logger.error(f"Server {self.server_name}: Initialize error: {resp['error']}") 195 | return False 196 | if "result" in resp: 197 | elapsed = asyncio.get_event_loop().time() - start 198 | logger.info(f"Server {self.server_name}: Initialized in {elapsed:.2f}s") 199 | note = {"jsonrpc": "2.0", "method": "notifications/initialized"} 200 | await self._send_message(note) 201 | init_result = resp["result"] 202 | self.server_capabilities = init_result.get("capabilities", {}) 203 | return True 204 | await asyncio.sleep(0.05) 205 | logger.error(f"Server {self.server_name}: Initialize timed out after {timeout}s") 206 | return False 207 | 208 | async def list_tools(self): 209 | if not self.process: 210 | return [] 211 | self.request_id += 1 212 | rid = self.request_id 213 | req = { 214 | "jsonrpc": "2.0", 215 | "id": rid, 216 | "method": "tools/list", 217 | "params": {} 218 | } 219 | await self._send_message(req) 220 | 221 | start = asyncio.get_event_loop().time() 222 | timeout = 10 # Increased timeout to 10 seconds 223 | while asyncio.get_event_loop().time() - start < timeout: 224 | if rid in self.responses: 225 | resp = self.responses[rid] 226 | del self.responses[rid] 227 | if "error" in resp: 228 | logger.error(f"Server {self.server_name}: List tools error: {resp['error']}") 229 | return [] 230 | if "result" in resp and "tools" in resp["result"]: 231 | elapsed = asyncio.get_event_loop().time() - start 232 | logger.info(f"Server {self.server_name}: Listed {len(resp['result']['tools'])} tools in {elapsed:.2f}s") 233 | self.tools = resp["result"]["tools"] 234 | return self.tools 235 | await asyncio.sleep(0.05) 236 | logger.error(f"Server {self.server_name}: List tools timed out after {timeout}s") 237 | return [] 238 | 239 | async def call_tool(self, tool_name: str, arguments: dict): 240 | if not self.process: 241 | return {"error": "Not started"} 242 | self.request_id += 1 243 | rid = self.request_id 244 | req = { 245 | "jsonrpc": "2.0", 246 | "id": rid, 247 | "method": "tools/call", 248 | "params": { 249 | "name": tool_name, 250 | "arguments": arguments 251 | } 252 | } 253 | await self._send_message(req) 254 | 255 | start = asyncio.get_event_loop().time() 256 | timeout = 3600 # Increased timeout to 30 seconds 257 | while asyncio.get_event_loop().time() - start < timeout: 258 | if rid in self.responses: 259 | resp = self.responses[rid] 260 | del self.responses[rid] 261 | if "error" in resp: 262 | logger.error(f"Server {self.server_name}: Tool {tool_name} error: {resp['error']}") 263 | return {"error": resp["error"]} 264 | if "result" in resp: 265 | elapsed = asyncio.get_event_loop().time() - start 266 | logger.info(f"Server {self.server_name}: Tool {tool_name} completed in {elapsed:.2f}s") 267 | return resp["result"] 268 | await asyncio.sleep(0.01) # Reduced sleep interval for more responsive streaming 269 | if asyncio.get_event_loop().time() - start > 5: # Log warning after 5 seconds 270 | logger.warning(f"Server {self.server_name}: Tool {tool_name} taking longer than 5s...") 271 | logger.error(f"Server {self.server_name}: Tool {tool_name} timed out after {timeout}s") 272 | return {"error": f"Timeout waiting for tool result after {timeout}s"} 273 | 274 | async def _send_message(self, message: dict): 275 | if not self.process or self._shutdown: 276 | logger.error(f"Server {self.server_name}: Cannot send message - process not running or shutting down") 277 | return False 278 | try: 279 | data = json.dumps(message) + "\n" 280 | self.process.stdin.write(data.encode()) 281 | await self.process.stdin.drain() 282 | return True 283 | except Exception as e: 284 | logger.error(f"Server {self.server_name}: Error sending message: {str(e)}") 285 | return False 286 | 287 | async def stop(self): 288 | async with self._cleanup_lock: 289 | if self._shutdown: 290 | return 291 | self._shutdown = True 292 | 293 | if self.receive_task and not self.receive_task.done(): 294 | self.receive_task.cancel() 295 | try: 296 | await self.receive_task 297 | except asyncio.CancelledError: 298 | pass 299 | 300 | if self.process: 301 | try: 302 | # Try to send a shutdown notification first 303 | try: 304 | note = {"jsonrpc": "2.0", "method": "shutdown"} 305 | await self._send_message(note) 306 | # Give a small window for the process to react 307 | await asyncio.sleep(0.5) 308 | except: 309 | pass 310 | 311 | # Close stdin before terminating to prevent pipe errors 312 | if self.process.stdin: 313 | self.process.stdin.close() 314 | 315 | # Try graceful shutdown first 316 | self.process.terminate() 317 | try: 318 | # Use a shorter timeout to make cleanup faster 319 | await asyncio.wait_for(self.process.wait(), timeout=1.0) 320 | except asyncio.TimeoutError: 321 | # Force kill if graceful shutdown fails 322 | logger.warning(f"Server {self.server_name}: Force killing process after timeout") 323 | self.process.kill() 324 | try: 325 | await asyncio.wait_for(self.process.wait(), timeout=1.0) 326 | except asyncio.TimeoutError: 327 | logger.error(f"Server {self.server_name}: Process did not respond to SIGKILL") 328 | except Exception as e: 329 | logger.error(f"Server {self.server_name}: Error during process cleanup: {str(e)}") 330 | finally: 331 | # Make sure we clear the reference 332 | self.process = None 333 | 334 | # Alias close to stop for backward compatibility 335 | async def close(self): 336 | await self.stop() 337 | 338 | # Add async context manager support 339 | async def __aenter__(self): 340 | await self.start() 341 | return self 342 | 343 | async def __aexit__(self, exc_type, exc_val, exc_tb): 344 | await self.stop() 345 | 346 | async def generate_text(conversation: List[Dict], model_cfg: Dict, 347 | all_functions: List[Dict], stream: bool = False) -> Union[Dict, AsyncGenerator]: 348 | """ 349 | Generate text using the specified provider. 350 | 351 | Args: 352 | conversation: The conversation history 353 | model_cfg: Configuration for the model 354 | all_functions: Available functions for the model to call 355 | stream: Whether to stream the response 356 | 357 | Returns: 358 | If stream=False: Dict containing assistant_text and tool_calls 359 | If stream=True: AsyncGenerator yielding chunks of assistant text and tool calls 360 | """ 361 | provider = model_cfg.get("provider", "").lower() 362 | 363 | if provider == "openai": 364 | if stream: 365 | return generate_with_openai(conversation, model_cfg, all_functions, stream=True) 366 | else: 367 | return await generate_with_openai(conversation, model_cfg, all_functions, stream=False) 368 | 369 | if provider == "msazureopenai": 370 | try: 371 | if stream: 372 | return generate_with_msazure_openai(conversation, model_cfg, all_functions, stream=True) 373 | else: 374 | return await generate_with_msazure_openai(conversation, model_cfg, all_functions, stream=False) 375 | except Exception as e: 376 | traceback.print_exc() 377 | raise e 378 | 379 | 380 | # For non-streaming providers, wrap the response in an async generator if streaming is requested 381 | if stream: 382 | async def wrap_response(): 383 | if provider == "anthropic": 384 | result = await generate_with_anthropic(conversation, model_cfg, all_functions) 385 | elif provider == "ollama": 386 | result = await generate_with_ollama(conversation, model_cfg, all_functions) 387 | elif provider == "lmstudio": 388 | result = await generate_with_lmstudio(conversation, model_cfg, all_functions) 389 | else: 390 | result = {"assistant_text": f"Unsupported provider '{provider}'", "tool_calls": []} 391 | yield result 392 | return wrap_response() 393 | 394 | # Non-streaming path 395 | if provider == "anthropic": 396 | return await generate_with_anthropic(conversation, model_cfg, all_functions) 397 | elif provider == "ollama": 398 | return await generate_with_ollama(conversation, model_cfg, all_functions) 399 | elif provider == "lmstudio": 400 | return await generate_with_lmstudio(conversation, model_cfg, all_functions) 401 | else: 402 | return {"assistant_text": f"Unsupported provider '{provider}'", "tool_calls": []} 403 | 404 | async def log_messages_to_file(messages: List[Dict], functions: List[Dict], log_path: str): 405 | """ 406 | Log messages and function definitions to a JSONL file. 407 | 408 | Args: 409 | messages: List of messages to log 410 | functions: List of function definitions 411 | log_path: Path to the log file 412 | """ 413 | try: 414 | # Create directory if it doesn't exist 415 | log_dir = os.path.dirname(log_path) 416 | if log_dir and not os.path.exists(log_dir): 417 | os.makedirs(log_dir) 418 | 419 | # Append to file 420 | with open(log_path, "a") as f: 421 | f.write(json.dumps({ 422 | "messages": messages, 423 | "functions": functions 424 | }) + "\n") 425 | except Exception as e: 426 | logger.error(f"Error logging messages to {log_path}: {str(e)}") 427 | 428 | async def process_tool_call(tc: Dict, servers: Dict[str, MCPClient], quiet_mode: bool) -> Optional[Dict]: 429 | """Process a single tool call and return the result""" 430 | func_name = tc["function"]["name"] 431 | func_args_str = tc["function"].get("arguments", "{}") 432 | try: 433 | func_args = json.loads(func_args_str) 434 | except: 435 | func_args = {} 436 | 437 | parts = func_name.split("_", 1) 438 | if len(parts) != 2: 439 | return { 440 | "role": "tool", 441 | "tool_call_id": tc["id"], 442 | "name": func_name, 443 | "content": json.dumps({"error": "Invalid function name format"}) 444 | } 445 | 446 | srv_name, tool_name = parts 447 | if not quiet_mode: 448 | print(f"\nView result from {tool_name} from {srv_name} {json.dumps(func_args)}") 449 | else: 450 | print(f"\nProcessing tool call...{tool_name}") 451 | 452 | if srv_name not in servers: 453 | return { 454 | "role": "tool", 455 | "tool_call_id": tc["id"], 456 | "name": func_name, 457 | "content": json.dumps({"error": f"Unknown server: {srv_name}"}) 458 | } 459 | 460 | # Get the tool's schema 461 | tool_schema = None 462 | for tool in servers[srv_name].tools: 463 | if tool["name"] == tool_name: 464 | tool_schema = tool.get("inputSchema", {}) 465 | break 466 | 467 | if tool_schema: 468 | # Ensure required parameters are present 469 | required_params = tool_schema.get("required", []) 470 | for param in required_params: 471 | if param not in func_args: 472 | return { 473 | "role": "tool", 474 | "tool_call_id": tc["id"], 475 | "name": func_name, 476 | "content": json.dumps({"error": f"Missing required parameter: {param}"}) 477 | } 478 | 479 | result = await servers[srv_name].call_tool(tool_name, func_args) 480 | if not quiet_mode: 481 | print(json.dumps(result, indent=2)) 482 | 483 | return { 484 | "role": "tool", 485 | "tool_call_id": tc["id"], 486 | "name": func_name, 487 | "content": json.dumps(result) 488 | } 489 | 490 | 491 | class MCPAgent: 492 | @classmethod 493 | async def create(cls, 494 | model_name: Optional[str] = None, 495 | provider_config: dict = None, # New parameter 496 | mcp_server_config: Optional[dict] = None, # Renamed from config 497 | mcp_server_config_path: str = "mcp_config.json", # Renamed from config_path 498 | quiet_mode: bool = False, 499 | log_messages_path: Optional[str] = None, 500 | stream: bool = False) -> "MCPAgent": 501 | """ 502 | Create an instance of the MCPAgent using MCPAgent.create(...) 503 | async class method so that the initialization can be awaited. 504 | 505 | Args: 506 | model_name: Name of the model to use (optional) 507 | provider_config: Provider configuration dictionary (required) 508 | mcp_server_config: MCP server configuration dict (optional, if not provided will load from mcp_server_config_path) 509 | mcp_server_config_path: Path to the MCP server configuration file (default: mcp_config.json) 510 | quiet_mode: Whether to suppress intermediate output (default: False) 511 | log_messages_path: Path to log messages in JSONL format (optional) 512 | stream: Whether to stream the response (default: False) 513 | 514 | Returns: 515 | An instance of MCPAgent 516 | """ 517 | if provider_config is None: 518 | # This should ideally be handled by loading a default or raising an error earlier, 519 | # but for now, let's ensure it's not None. 520 | # In practice, run_interaction loads it. 521 | raise ValueError("provider_config cannot be None") 522 | 523 | obj = cls() 524 | await obj._initialize( 525 | model_name=model_name, 526 | provider_config=provider_config, 527 | mcp_server_config=mcp_server_config, 528 | mcp_server_config_path=mcp_server_config_path, 529 | quiet_mode=quiet_mode, 530 | log_messages_path=log_messages_path, 531 | stream=stream 532 | ) 533 | return obj 534 | 535 | def __init__(self): 536 | pass 537 | 538 | async def _initialize(self, 539 | model_name: Optional[str] = None, 540 | provider_config: dict = None, # New parameter 541 | mcp_server_config: Optional[dict] = None, # Renamed from config 542 | mcp_server_config_path: str = "mcp_config.json", # Renamed from config_path 543 | quiet_mode: bool = False, 544 | log_messages_path: Optional[str] = None, 545 | stream: bool = False) -> Union[str, AsyncGenerator[str, None]]: 546 | 547 | self.stream = stream 548 | self.log_messages_path = log_messages_path 549 | self.quiet_mode = quiet_mode 550 | 551 | # 1) Load MCP Server config if not provided directly 552 | if mcp_server_config is None: 553 | mcp_server_config = await load_config_from_file(mcp_server_config_path) 554 | 555 | servers_cfg = mcp_server_config.get("mcpServers", {}) 556 | models_cfg = provider_config.get("models", []) # Get models from provider_config 557 | 558 | # 2) Choose a model 559 | self.chosen_model = None 560 | if model_name: 561 | for m in models_cfg: 562 | if m.get("model") == model_name or m.get("title") == model_name: 563 | self.chosen_model = m 564 | break 565 | if not self.chosen_model: # If specific model not found, try default 566 | for m in models_cfg: 567 | if m.get("default"): 568 | self.chosen_model = m 569 | break 570 | else: # If no model_name specified, pick default 571 | for m in models_cfg: 572 | if m.get("default"): 573 | self.chosen_model = m 574 | break 575 | if not self.chosen_model and models_cfg: # If no default, pick first 576 | self.chosen_model = models_cfg[0] 577 | 578 | if not self.chosen_model: 579 | error_msg = "No suitable model found in provider_config." 580 | if stream: 581 | async def error_gen(): 582 | yield error_msg 583 | return error_gen() 584 | return error_msg 585 | 586 | # 3) Start servers 587 | self.servers = {} 588 | self.all_functions = [] 589 | for server_name, conf in servers_cfg.items(): 590 | if "url" in conf: # SSE server 591 | client = SSEMCPClient(server_name, conf["url"]) 592 | else: # Local process-based server 593 | client = MCPClient( 594 | server_name=server_name, 595 | command=conf.get("command"), 596 | args=conf.get("args", []), 597 | env=conf.get("env", {}), 598 | cwd=conf.get("cwd", None) 599 | ) 600 | ok = await client.start() 601 | if not ok: 602 | if not quiet_mode: 603 | print(f"[WARN] Could not start server {server_name}") 604 | continue 605 | else: 606 | print(f"[OK] {server_name}") 607 | 608 | # gather tools 609 | tools = await client.list_tools() 610 | for t in tools: 611 | input_schema = t.get("inputSchema") or {"type": "object", "properties": {}} 612 | fn_def = { 613 | "name": f"{server_name}_{t['name']}", 614 | "description": t.get("description", ""), 615 | "parameters": input_schema 616 | } 617 | self.all_functions.append(fn_def) 618 | 619 | self.servers[server_name] = client 620 | 621 | if not self.servers: 622 | error_msg = "No MCP servers could be started." 623 | if stream: 624 | async def error_gen(): 625 | yield error_msg 626 | return error_gen() 627 | return error_msg 628 | 629 | self.conversation = [] 630 | 631 | # 4) Build conversation 632 | # Get system message - either from systemMessageFile, systemMessage, or default 633 | system_msg = "You are a helpful assistant." 634 | if "systemMessageFile" in self.chosen_model: 635 | try: 636 | with open(self.chosen_model["systemMessageFile"], "r", encoding="utf-8") as f: 637 | system_msg = f.read() 638 | except Exception as e: 639 | logger.warning(f"Failed to read system message file: {e}") 640 | # Fall back to direct systemMessage if available 641 | self.conversation.append({"role": "system", "content": self.chosen_model.get("systemMessage", system_msg)}) 642 | else: 643 | self.conversation.append({"role": "system", "content": self.chosen_model.get("systemMessage", system_msg)}) 644 | if "systemMessageFiles" in self.chosen_model: 645 | for file in self.chosen_model["systemMessageFiles"]: 646 | try: 647 | with open(file, "r", encoding="utf-8") as f: 648 | system_msg = f.read() 649 | self.conversation.append({"role": "system", "content": "File: " + file + "\n" + system_msg}) 650 | except Exception as e: 651 | logger.warning(f"Failed to read system message file: {e}") 652 | 653 | async def cleanup(self): 654 | """Clean up servers and log messages""" 655 | if self.log_messages_path: 656 | await log_messages_to_file(self.conversation, self.all_functions, self.log_messages_path) 657 | for cli in self.servers.values(): 658 | await cli.stop() 659 | self.servers.clear() 660 | 661 | async def prompt(self, user_query): 662 | """ 663 | Prompt the specified model along with the configured MCP servers. 664 | 665 | Args: 666 | user_query: The user's query 667 | 668 | Returns: 669 | If self.stream=False: The final text response 670 | If self.stream=True: AsyncGenerator yielding chunks of the response 671 | """ 672 | self.conversation.append({"role": "user", "content": user_query}) 673 | 674 | if self.stream: 675 | async def stream_response(): 676 | try: 677 | while True: # Main conversation loop 678 | generator = await generate_text(self.conversation, self.chosen_model, self.all_functions, stream=True) 679 | accumulated_text = "" 680 | tool_calls_processed = False 681 | 682 | async for chunk in await generator: 683 | if chunk.get("is_chunk", False): 684 | # Immediately yield each token without accumulation 685 | if chunk.get("token", False): 686 | yield chunk["assistant_text"] 687 | accumulated_text += chunk["assistant_text"] 688 | else: 689 | # This is the final chunk with tool calls 690 | if accumulated_text != chunk["assistant_text"]: 691 | # If there's any remaining text, yield it 692 | remaining = chunk["assistant_text"][len(accumulated_text):] 693 | if remaining: 694 | yield remaining 695 | 696 | # Process any tool calls from the final chunk 697 | tool_calls = chunk.get("tool_calls", []) 698 | if tool_calls: 699 | # Add type field to each tool call 700 | for tc in tool_calls: 701 | tc["type"] = "function" 702 | # Add the assistant's message with tool calls 703 | assistant_message = { 704 | "role": "assistant", 705 | "content": chunk["assistant_text"], 706 | "tool_calls": tool_calls 707 | } 708 | self.conversation.append(assistant_message) 709 | 710 | # Process each tool call 711 | for tc in tool_calls: 712 | if tc.get("function", {}).get("name"): 713 | result = await process_tool_call(tc, self.servers, self.quiet_mode) 714 | if result: 715 | self.conversation.append(result) 716 | tool_calls_processed = True 717 | 718 | # Break the loop if no tool calls were processed 719 | if not tool_calls_processed: 720 | break 721 | 722 | finally: 723 | pass 724 | return stream_response() 725 | else: 726 | try: 727 | final_text = "" 728 | while True: 729 | gen_result = await generate_text(self.conversation, self.chosen_model, self.all_functions, stream=False) 730 | 731 | assistant_text = gen_result["assistant_text"] 732 | final_text = assistant_text 733 | tool_calls = gen_result.get("tool_calls", []) 734 | 735 | # Add the assistant's message 736 | assistant_message = {"role": "assistant", "content": assistant_text} 737 | if tool_calls: 738 | # Add type field to each tool call 739 | for tc in tool_calls: 740 | tc["type"] = "function" 741 | assistant_message["tool_calls"] = tool_calls 742 | self.conversation.append(assistant_message) 743 | logger.info(f"Added assistant message: {json.dumps(assistant_message, indent=2)}") 744 | 745 | if not tool_calls: 746 | break 747 | 748 | for tc in tool_calls: 749 | result = await process_tool_call(tc, self.servers, self.quiet_mode) 750 | if result: 751 | self.conversation.append(result) 752 | logger.info(f"Added tool result: {json.dumps(result, indent=2)}") 753 | 754 | finally: 755 | return final_text 756 | 757 | async def run_interaction( 758 | user_query: str, 759 | model_name: Optional[str] = None, 760 | provider_config_path: str = "config.yml", # New parameter for provider config 761 | mcp_server_config: Optional[dict] = None, # Renamed from config 762 | mcp_server_config_path: str = "mcp_config.json", # Renamed from config_path 763 | quiet_mode: bool = False, 764 | log_messages_path: Optional[str] = None, 765 | stream: bool = False 766 | ) -> Union[str, AsyncGenerator[str, None]]: 767 | """ 768 | Run an interaction with the MCP servers. 769 | 770 | Args: 771 | user_query: The user's query 772 | model_name: Name of the model to use (optional) 773 | provider_config_path: Path to the provider configuration file (default: config.yml) 774 | mcp_server_config: MCP server configuration dict (optional, if not provided will load from mcp_server_config_path) 775 | mcp_server_config_path: Path to the MCP server configuration file (default: mcp_config.json) 776 | quiet_mode: Whether to suppress intermediate output (default: False) 777 | log_messages_path: Path to log messages in JSONL format (optional) 778 | stream: Whether to stream the response (default: False) 779 | 780 | Returns: 781 | If stream=False: The final text response 782 | If stream=True: AsyncGenerator yielding chunks of the response 783 | """ 784 | provider_config = await load_config_from_file(provider_config_path) 785 | agent = await MCPAgent.create( 786 | model_name=model_name, 787 | provider_config=provider_config, # Pass loaded provider_config 788 | mcp_server_config=mcp_server_config, # Pass mcp_server_config 789 | mcp_server_config_path=mcp_server_config_path, # Pass mcp_server_config_path 790 | quiet_mode=quiet_mode, 791 | log_messages_path=log_messages_path, 792 | stream=stream 793 | ) 794 | response = await agent.prompt(user_query) 795 | await agent.cleanup() 796 | return response 797 | -------------------------------------------------------------------------------- /src/dolphin_mcp/providers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provider-specific implementations for different LLM services. 3 | """ 4 | 5 | __all__ = ["openai", "anthropic", "ollama", "lmstudio"] 6 | -------------------------------------------------------------------------------- /src/dolphin_mcp/providers/anthropic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Anthropic provider implementation for Dolphin MCP. 3 | """ 4 | 5 | import os 6 | import logging 7 | import json 8 | import hashlib 9 | import re 10 | import atexit 11 | import asyncio 12 | import sys 13 | import time 14 | from typing import Dict, List, Any 15 | 16 | # Set up logger 17 | logger = logging.getLogger(__name__) 18 | 19 | # Keep track of client resources that need cleanup 20 | _active_clients = set() 21 | 22 | # Track last request time for rate limiting 23 | _last_request_time = 0.0 24 | 25 | # Get rate limit from env var (in seconds) or default to 60 seconds (1 minute) 26 | def get_rate_limit_seconds(): 27 | try: 28 | return float(os.getenv("ANTHROPIC_RATE_LIMIT_SECONDS", "60")) 29 | except (ValueError, TypeError): 30 | logger.warning("Invalid ANTHROPIC_RATE_LIMIT_SECONDS value, using default of 60 seconds") 31 | return 60.0 32 | 33 | def get_caching_enabled(): 34 | try: 35 | return bool(os.getenv("ANTHROPIC_CACHING_ENABLED", "true")) 36 | except (ValueError, TypeError): 37 | logger.warning("Invalid ANTHROPIC_CACHING_ENABLED value, using default of True") 38 | return True 39 | 40 | def _cleanup_clients(): 41 | """Ensure all active clients are closed during interpreter shutdown""" 42 | # Skip if no clients to clean up 43 | if not _active_clients: 44 | return 45 | 46 | try: 47 | # Try to get or create an event loop for cleanup 48 | try: 49 | loop = asyncio.get_event_loop() 50 | if loop.is_closed(): 51 | # If the main loop is closed, create a new one 52 | loop = asyncio.new_event_loop() 53 | asyncio.set_event_loop(loop) 54 | except RuntimeError: 55 | # No event loop in thread, create a new one 56 | loop = asyncio.new_event_loop() 57 | asyncio.set_event_loop(loop) 58 | 59 | # Now use the loop to clean up clients 60 | for client in list(_active_clients): 61 | try: 62 | if hasattr(client, 'close'): 63 | if asyncio.iscoroutinefunction(client.close): 64 | try: 65 | loop.run_until_complete(client.close()) 66 | except Exception as e: 67 | logger.error(f"Error in async client close: {e}") 68 | else: 69 | client.close() 70 | except Exception as e: 71 | logger.error(f"Error cleaning up client during shutdown: {e}") 72 | finally: 73 | _active_clients.discard(client) 74 | 75 | # Close our temporary loop if we created one 76 | try: 77 | if not loop.is_closed(): 78 | loop.close() 79 | except: 80 | pass 81 | 82 | except Exception as e: 83 | # Last resort - make sure we don't crash during interpreter shutdown 84 | logger.error(f"Error during client cleanup at exit: {e}") 85 | 86 | # Clear the set to help with garbage collection 87 | _active_clients.clear() 88 | 89 | # Register the cleanup function to run at exit 90 | atexit.register(_cleanup_clients) 91 | 92 | def generate_tool_id(tool_name: str) -> str: 93 | """ 94 | Generate a deterministic tool ID from the tool name. 95 | 96 | Args: 97 | tool_name: The name of the tool 98 | 99 | Returns: 100 | A string ID for the tool 101 | """ 102 | # Create a hash from just the tool name 103 | name_underscored = re.sub(r'[^a-zA-Z0-9]', '_', tool_name) 104 | return name_underscored 105 | 106 | def format_tools(all_functions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 107 | """ 108 | Format functions into Anthropic's tool format. 109 | 110 | Args: 111 | all_functions: List of function definitions 112 | 113 | Returns: 114 | List of formatted tools for Anthropic API 115 | """ 116 | anthropic_tools = [] 117 | 118 | for i, func in enumerate(all_functions): 119 | # Extract required fields 120 | name = func.get("name") 121 | description = func.get("description", "") 122 | parameters = func.get("parameters", {}) 123 | 124 | if not name: 125 | logger.warning(f"Function at index {i} has no name, skipping") 126 | continue 127 | 128 | # Format exactly according to Anthropic's documentation 129 | tool = { 130 | "name": name, 131 | "description": description, 132 | "input_schema": parameters 133 | } 134 | 135 | # Verify input_schema is properly formatted 136 | if not isinstance(tool["input_schema"], dict): 137 | logger.warning(f"Invalid input_schema for function {name}, using default") 138 | tool["input_schema"] = {"type": "object", "properties": {}} 139 | 140 | # Ensure input_schema has required type field 141 | if "type" not in tool["input_schema"]: 142 | tool["input_schema"]["type"] = "object" 143 | 144 | anthropic_tools.append(tool) 145 | 146 | return anthropic_tools 147 | 148 | async def generate_with_anthropic(conversation, model_cfg, all_functions): 149 | """ 150 | Generate text using Anthropic's API. 151 | 152 | Args: 153 | conversation: The conversation history 154 | model_cfg: Configuration for the model 155 | all_functions: Available functions for the model to call 156 | 157 | Returns: 158 | Dict containing assistant_text and tool_calls 159 | """ 160 | from anthropic import AsyncAnthropic, APIError as AnthropicAPIError 161 | 162 | global _last_request_time 163 | 164 | # Apply rate limiting 165 | rate_limit_seconds = get_rate_limit_seconds() 166 | current_time = time.time() 167 | time_since_last_request = current_time - _last_request_time 168 | 169 | if time_since_last_request < rate_limit_seconds: 170 | # Need to wait before making a new request 171 | wait_time = rate_limit_seconds - time_since_last_request 172 | logger.info(f"Rate limiting: Waiting {wait_time:.2f} seconds before making Anthropic API request") 173 | await asyncio.sleep(wait_time) 174 | 175 | # Update last request time after waiting (if needed) 176 | _last_request_time = time.time() 177 | 178 | anthro_api_key = model_cfg.get("apiKey", os.getenv("ANTHROPIC_API_KEY")) 179 | 180 | # Initialize result outside context manager 181 | result = {"assistant_text": "", "tool_calls": []} 182 | 183 | # Create the client 184 | client = None 185 | try: 186 | client = AsyncAnthropic(api_key=anthro_api_key) 187 | # Register for cleanup at interpreter shutdown - only if we need to 188 | if client: 189 | _active_clients.add(client) 190 | 191 | # Store tool ID mappings to ensure consistency 192 | tool_id_map = {} 193 | 194 | # Helper function to get or create a tool ID 195 | def get_or_create_tool_id(tool_name): 196 | if tool_name in tool_id_map: 197 | return tool_id_map[tool_name] 198 | else: 199 | new_id = generate_tool_id(tool_name) 200 | tool_id_map[tool_name] = new_id 201 | return new_id 202 | 203 | model_name = model_cfg["model"] 204 | temperature = model_cfg.get("temperature", 0.7) 205 | top_k = model_cfg.get("top_k", None) 206 | top_p = model_cfg.get("top_p", None) 207 | max_tokens = model_cfg.get("max_tokens", 1024) 208 | 209 | # Extract system messages and non-system messages 210 | system_messages = [] 211 | non_system_messages = [] 212 | last_assistant_content = None 213 | 214 | # Process conversation messages for Anthropic format 215 | for i, msg in enumerate(conversation): 216 | role = msg.get("role", "") 217 | content = msg.get("content", "") 218 | 219 | if role == "system": 220 | system_messages.append({ 221 | "type": "text", 222 | "text": content, 223 | }) 224 | elif role == "tool": 225 | new_msg = { 226 | "role": "user", 227 | "content": [{ 228 | "type": "tool_result", 229 | "tool_use_id": msg.get("tool_call_id"), 230 | "content": msg.get("content") 231 | }] 232 | } 233 | non_system_messages.append(new_msg) 234 | elif role == "assistant" and isinstance(content, str) and msg.get("tool_calls"): 235 | # Create a new message with content blocks for text and tool_use 236 | new_msg = {"role": "assistant", "content": []} 237 | 238 | # Add text block if there's content 239 | if content: 240 | last_assistant_content = {"type": "text", "text": content} 241 | new_msg["content"].append(last_assistant_content) 242 | 243 | # Add tool_use blocks for each tool call 244 | for tool_call in msg.get("tool_calls", []): 245 | if tool_call.get("type") == "function": 246 | func = tool_call.get("function", {}) 247 | func_name = func.get("name", "") 248 | tool_id = tool_call.get("id") 249 | 250 | # Parse arguments from string if needed 251 | arguments = func.get("arguments", "{}") 252 | if isinstance(arguments, str): 253 | try: 254 | tool_input = json.loads(arguments) 255 | except: 256 | tool_input = {"raw_input": arguments} 257 | else: 258 | tool_input = arguments 259 | 260 | # Create tool_use block 261 | tool_use = { 262 | "type": "tool_use", 263 | "id": tool_id, 264 | "name": func_name, 265 | "input": tool_input 266 | } 267 | new_msg["content"].append(tool_use) 268 | 269 | non_system_messages.append(new_msg) 270 | else: 271 | # Keep user and assistant messages as they are 272 | non_system_messages.append(msg) 273 | 274 | if get_caching_enabled() and last_assistant_content: 275 | last_assistant_content["cache_control"] = {"type": "ephemeral"} 276 | 277 | 278 | # Prepare API parameters, excluding None values 279 | api_params = { 280 | "model": model_name, 281 | "messages": non_system_messages, 282 | "max_tokens": max_tokens, 283 | "temperature": temperature, 284 | } 285 | 286 | # Format tools for Anthropic API 287 | if all_functions: 288 | # Format tools for Anthropic API 289 | anthropic_tools = format_tools(all_functions) 290 | 291 | # Only add tools if we have valid ones 292 | if anthropic_tools: 293 | api_params["tools"] = anthropic_tools 294 | 295 | # cache last tool (because this should be stable) 296 | if get_caching_enabled() and not last_assistant_content: 297 | anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} 298 | 299 | # Let Claude decide when to use tools instead of forcing it 300 | api_params["tool_choice"] = {"type": "auto"} 301 | else: 302 | logger.warning("No valid tools to add to the request") 303 | 304 | # Only add parameters if they have valid values 305 | if system_messages: 306 | api_params["system"] = system_messages 307 | if get_caching_enabled() and not last_assistant_content: 308 | for msg in system_messages: 309 | # do not cache if the first line contains "TODO.md" 310 | if "TODO.md" not in msg["text"].split("\n")[0]: 311 | msg["cache_control"] = {"type": "ephemeral"} 312 | if top_p is not None: 313 | api_params["top_p"] = top_p 314 | if top_k is not None and isinstance(top_k, int): 315 | api_params["top_k"] = top_k 316 | 317 | try: 318 | create_resp = await client.messages.create(**api_params) 319 | 320 | # Handle the case where content might be a list of TextBlock objects 321 | if create_resp.content: 322 | # Extract text properly from Anthropic response 323 | if isinstance(create_resp.content, list): 324 | # If content is a list of blocks, extract text from each block 325 | assistant_text = "" 326 | for block in create_resp.content: 327 | if hasattr(block, 'text'): 328 | assistant_text += block.text 329 | elif isinstance(block, dict) and 'text' in block: 330 | assistant_text += block['text'] 331 | elif isinstance(block, str): 332 | assistant_text += block 333 | else: 334 | # If content is a single item 335 | if hasattr(create_resp.content, 'text'): 336 | assistant_text = create_resp.content.text 337 | elif isinstance(create_resp.content, dict) and 'text' in create_resp.content: 338 | assistant_text = create_resp.content['text'] 339 | else: 340 | assistant_text = str(create_resp.content) 341 | else: 342 | assistant_text = "" 343 | 344 | # Check for tool calls in the response 345 | tool_calls = [] 346 | if hasattr(create_resp, 'content') and create_resp.content: 347 | # Look for tool calls in content blocks 348 | content_blocks = create_resp.content if isinstance(create_resp.content, list) else [create_resp.content] 349 | for block in content_blocks: 350 | if hasattr(block, 'type') and block.type == 'text': 351 | assistant_text = block.text 352 | 353 | # Check if this is a tool use block 354 | if hasattr(block, 'type') and block.type == 'tool_use': 355 | # Get the tool name and input 356 | tool_name = block.name 357 | tool_input = block.input 358 | tool_id = block.id 359 | 360 | # Generate a tool ID if one is not provided 361 | if not tool_id: 362 | tool_id = get_or_create_tool_id(tool_name) 363 | 364 | # Format as a function call for our system 365 | tool_call = { 366 | "id": tool_id, 367 | "type": "function", 368 | "function": { 369 | "name": tool_name, 370 | "arguments": json.dumps(tool_input) if isinstance(tool_input, dict) else tool_input 371 | } 372 | } 373 | tool_calls.append(tool_call) 374 | print(f"{assistant_text}") 375 | 376 | # Store the result to return after client is closed 377 | result = {"assistant_text": assistant_text, "tool_calls": tool_calls} 378 | 379 | except AnthropicAPIError as e: 380 | error_msg = str(e) 381 | logger.error(f"Anthropic API error: {error_msg}") 382 | result = {"assistant_text": f"Anthropic error: {error_msg}", "tool_calls": []} 383 | 384 | except Exception as e: 385 | import traceback 386 | logger.error(f"Unexpected error in Anthropic provider: {str(e)}") 387 | logger.error(traceback.format_exc()) 388 | result = {"assistant_text": f"Unexpected Anthropic error: {str(e)}", "tool_calls": []} 389 | 390 | finally: 391 | # Always clean up the client 392 | if client: 393 | try: 394 | # First remove from active clients to prevent double cleanup 395 | _active_clients.discard(client) 396 | # Then close the client 397 | await client.close() 398 | except Exception as e: 399 | logger.error(f"Error closing Anthropic client: {e}") 400 | 401 | # Return result after everything is cleaned up 402 | return result 403 | -------------------------------------------------------------------------------- /src/dolphin_mcp/providers/lmstudio.py: -------------------------------------------------------------------------------- 1 | """ 2 | LMStudio provider implementation for Dolphin MCP. 3 | 4 | This module enables integration with LM Studio models for inference and tool use, 5 | providing standardized interfaces to LMStudio's Python SDK. 6 | """ 7 | 8 | import json 9 | # import logging # Removed logging import 10 | import traceback 11 | import inspect 12 | import sys # Added for print to stderr 13 | from typing import Dict, List, Any, Optional, Union, AsyncGenerator, Callable 14 | 15 | # LMStudio imports - core API for LM models 16 | import lmstudio as lms 17 | from lmstudio import Chat 18 | 19 | # Constants 20 | DEFAULT_MODEL_SELECTION = None # Use default model in LMStudio 21 | 22 | # Configure logging 23 | # logger = logging.getLogger("dolphin_mcp") # Removed logger setup 24 | 25 | # Type definitions 26 | MessageType = Dict[str, Any] 27 | FunctionDefType = Dict[str, Any] 28 | ModelConfigType = Dict[str, Any] 29 | 30 | 31 | async def generate_with_lmstudio( 32 | conversation: List[MessageType], 33 | model_cfg: ModelConfigType, 34 | all_functions: List[FunctionDefType], 35 | stream: bool = False 36 | ) -> Union[Dict[str, Any], AsyncGenerator[Dict[str, Any], None]]: 37 | """ 38 | Generate text using LMStudio's SDK. 39 | 40 | Args: 41 | conversation: The conversation history in message format 42 | model_cfg: Configuration for the model including name and parameters 43 | all_functions: Available functions for the model to call as JSON schema 44 | stream: Whether to stream the response (not currently supported by LMStudio) 45 | 46 | Returns: 47 | Dict containing assistant_text and tool_calls, or an AsyncGenerator for streaming 48 | """ 49 | print(f"[DEBUG] --- Entering generate_with_lmstudio ---", file=sys.stderr) 50 | print(f"[DEBUG] Received conversation: {json.dumps(conversation, indent=2)}", file=sys.stderr) 51 | print(f"[DEBUG] Received model_cfg: {model_cfg}", file=sys.stderr) 52 | print(f"[DEBUG] Received all_functions: {json.dumps(all_functions, indent=2)}", file=sys.stderr) 53 | print(f"[DEBUG] Stream mode: {stream}", file=sys.stderr) 54 | 55 | # Streaming is not supported by LMStudio SDK yet 56 | if stream: 57 | print("[WARN] Streaming requested but not supported by LMStudio provider.", file=sys.stderr) 58 | print(f"[DEBUG] --- Exiting generate_with_lmstudio (streaming not supported) ---", file=sys.stderr) 59 | return {"assistant_text": "Streaming not supported by LMStudio provider", "tool_calls": []} 60 | 61 | try: 62 | # Get model configuration 63 | model_name = model_cfg.get("model", DEFAULT_MODEL_SELECTION) 64 | print(f"[DEBUG] Resolved LMStudio model name: {model_name}", file=sys.stderr) 65 | 66 | # Initialize the model 67 | print(f"[DEBUG] Initializing LMStudio model: lms.llm('{model_name}')", file=sys.stderr) 68 | model = lms.llm(model_name) 69 | print(f"[DEBUG] LMStudio model initialized: {model}", file=sys.stderr) 70 | 71 | # Get the last user message for the prompt 72 | print("[DEBUG] Extracting last user message...", file=sys.stderr) 73 | user_message = extract_last_user_message(conversation) 74 | if not user_message: 75 | print("[WARN] No user message found in conversation history.", file=sys.stderr) 76 | print(f"[DEBUG] --- Exiting generate_with_lmstudio (no user message) ---", file=sys.stderr) 77 | return {"assistant_text": "No user message provided.", "tool_calls": []} 78 | print(f"[DEBUG] Extracted user message: '{user_message}'", file=sys.stderr) 79 | 80 | # Extract system message for context 81 | print("[DEBUG] Extracting system message...", file=sys.stderr) 82 | system_message = extract_system_message(conversation) 83 | print(f"[DEBUG] Extracted system message: '{system_message}'", file=sys.stderr) 84 | 85 | # If there are functions, use the tool interface (model.act) 86 | if all_functions and len(all_functions) > 0: 87 | print(f"[INFO] Tool use detected. Preparing {len(all_functions)} functions for model.act.", file=sys.stderr) 88 | 89 | # Store tool calls here 90 | tool_calls = [] 91 | print("[DEBUG] Initialized empty tool_calls list.", file=sys.stderr) 92 | 93 | # Create callable Python functions from MCP function definitions 94 | python_functions = [] 95 | print("[DEBUG] Starting creation of Python function wrappers...", file=sys.stderr) 96 | for i, func_def in enumerate(all_functions): 97 | print(f"[DEBUG] Processing function definition {i+1}/{len(all_functions)}: {func_def.get('name')}", file=sys.stderr) 98 | # Use the standard docstring function creator 99 | py_func = create_python_function_standard_docstring(func_def, tool_calls) 100 | if py_func is not None: 101 | python_functions.append(py_func) 102 | print(f"[DEBUG] Successfully created and added wrapper for: {py_func.__name__}", file=sys.stderr) 103 | else: 104 | print(f"[WARN] Failed to create wrapper for function: {func_def.get('name')}", file=sys.stderr) 105 | print(f"[DEBUG] Finished creating wrappers. Total created: {len(python_functions)}", file=sys.stderr) 106 | 107 | # Log the prepared functions 108 | if python_functions: 109 | print("[DEBUG] Functions prepared for model.act:", file=sys.stderr) 110 | for f in python_functions: 111 | print(f"[DEBUG] - Name: {f.__name__}, Docstring: '{f.__doc__}'", file=sys.stderr) 112 | else: 113 | print("[WARN] No valid Python function wrappers were created for tool use.", file=sys.stderr) 114 | # Decide if we should proceed without tools or return an error. 115 | # For now, let's proceed as if no tools were requested. 116 | # This might need adjustment based on desired behavior. 117 | print("[WARN] Proceeding with regular chat inference as no tools could be prepared.", file=sys.stderr) 118 | # Fall through to the 'else' block below 119 | 120 | # Only proceed with model.act if we have functions 121 | if python_functions: 122 | # Create a clean chat object *without* system prompt for model.act's on_message 123 | print("[DEBUG] Creating Chat() object for model.act on_message callback.", file=sys.stderr) 124 | response_chat = Chat() 125 | 126 | # Execute model with tools using model.act() 127 | print(f"[INFO] Calling model.act(prompt='{user_message[:100]}...', tools=[...], on_message=...)", file=sys.stderr) 128 | try: 129 | model.act( 130 | user_message, # Pass only the user prompt 131 | python_functions, # Pass the list of Python functions 132 | on_message=response_chat.append # Collect all messages 133 | ) 134 | print("[INFO] model.act() completed successfully.", file=sys.stderr) 135 | except Exception as act_error: 136 | print(f"[ERROR] Exception during model.act(): {act_error}", file=sys.stderr) 137 | print(f"[DEBUG] {traceback.format_exc()}", file=sys.stderr) 138 | response_text = str(response_chat) # Get any text collected before error 139 | print(f"[DEBUG] Text collected by response_chat before error: {response_text}", file=sys.stderr) 140 | print(f"[DEBUG] Tool calls collected before error: {tool_calls}", file=sys.stderr) 141 | print(f"[DEBUG] --- Exiting generate_with_lmstudio (model.act error) ---", file=sys.stderr) 142 | return { 143 | "assistant_text": f"Error during tool execution: {act_error}\nCollected text: {response_text}", 144 | "tool_calls": tool_calls 145 | } 146 | 147 | # Get the full text response collected by the chat 148 | response_text = str(response_chat) 149 | print(f"[DEBUG] Full response collected by response_chat after model.act(): {response_text}", file=sys.stderr) 150 | print(f"[DEBUG] Final tool_calls collected: {tool_calls}", file=sys.stderr) 151 | 152 | # Return the collected text and any captured tool calls 153 | result = { 154 | "assistant_text": response_text, # Return the full chat history string for now 155 | "tool_calls": tool_calls # Populated by the wrapper functions 156 | } 157 | print(f"[DEBUG] --- Exiting generate_with_lmstudio (tool mode success) ---", file=sys.stderr) 158 | return result 159 | 160 | # Regular chat without tools (or if tool prep failed) 161 | print("[INFO] Using regular chat inference (model.respond).", file=sys.stderr) 162 | print(f"[DEBUG] Creating Chat object. System message provided: {bool(system_message)}", file=sys.stderr) 163 | chat = Chat(system_message) if system_message else Chat() 164 | print(f"[DEBUG] Adding user message to chat: '{user_message}'", file=sys.stderr) 165 | chat.add_user_message(user_message) 166 | print(f"[DEBUG] Chat history before respond: {str(chat)}", file=sys.stderr) 167 | 168 | print("[INFO] Calling model.respond(chat)...", file=sys.stderr) 169 | response = model.respond(chat) 170 | print("[INFO] model.respond() completed.", file=sys.stderr) 171 | print(f"[DEBUG] Raw response from model.respond(): {response}", file=sys.stderr) 172 | 173 | result = { 174 | "assistant_text": response, 175 | "tool_calls": [] 176 | } 177 | print(f"[DEBUG] --- Exiting generate_with_lmstudio (respond mode success) ---", file=sys.stderr) 178 | return result 179 | 180 | except Exception as e: 181 | print(f"[ERROR] Unhandled exception in generate_with_lmstudio: {str(e)}", file=sys.stderr) 182 | print(f"[DEBUG] {traceback.format_exc()}", file=sys.stderr) 183 | print(f"[DEBUG] --- Exiting generate_with_lmstudio (unhandled exception) ---", file=sys.stderr) 184 | return {"assistant_text": f"LMStudio provider error: {str(e)}", "tool_calls": []} 185 | 186 | 187 | def extract_last_user_message(conversation: List[MessageType]) -> str: 188 | """Extract the last user message from the conversation.""" 189 | print("[DEBUG] --- Entering extract_last_user_message ---", file=sys.stderr) 190 | for i, message in enumerate(reversed(conversation)): 191 | print(f"[DEBUG] Checking message {-i-1}: role={message.get('role')}", file=sys.stderr) 192 | if message.get("role") == "user": 193 | content = message.get("content", "") 194 | print(f"[DEBUG] Found user message. Content type: {type(content)}", file=sys.stderr) 195 | if isinstance(content, list): 196 | content_text = "" 197 | for j, part in enumerate(content): 198 | print(f"[DEBUG] Processing content part {j}: type={part.get('type')}", file=sys.stderr) 199 | if isinstance(part, dict) and part.get("type") == "text": 200 | text_part = part.get("text", "") 201 | content_text += text_part 202 | print(f"[DEBUG] Added text part: '{text_part[:50]}...'", file=sys.stderr) 203 | print(f"[DEBUG] Extracted text from list content: '{content_text[:100]}...'", file=sys.stderr) 204 | print("[DEBUG] --- Exiting extract_last_user_message (found list) ---", file=sys.stderr) 205 | return content_text 206 | elif isinstance(content, str): 207 | print(f"[DEBUG] Extracted text from string content: '{content[:100]}...'", file=sys.stderr) 208 | print("[DEBUG] --- Exiting extract_last_user_message (found str) ---", file=sys.stderr) 209 | return content 210 | print("[WARN] No user message found in conversation.", file=sys.stderr) 211 | print("[DEBUG] --- Exiting extract_last_user_message (not found) ---", file=sys.stderr) 212 | return "" # Return empty string if no user message found 213 | 214 | def extract_system_message(conversation: List[MessageType]) -> str: 215 | """Extract the system message from the conversation.""" 216 | print("[DEBUG] --- Entering extract_system_message ---", file=sys.stderr) 217 | system_message = "" 218 | found = False 219 | for i, message in enumerate(conversation): 220 | print(f"[DEBUG] Checking message {i}: role={message.get('role')}", file=sys.stderr) 221 | if message.get("role") == "system": 222 | found = True 223 | content = message.get("content", "") 224 | print(f"[DEBUG] Found system message. Content type: {type(content)}", file=sys.stderr) 225 | if isinstance(content, list): 226 | for j, part in enumerate(content): 227 | print(f"[DEBUG] Processing content part {j}: type={part.get('type')}", file=sys.stderr) 228 | if isinstance(part, dict) and part.get("type") == "text": 229 | text_part = part.get("text", "") 230 | system_message += text_part + "\n" 231 | print(f"[DEBUG] Added text part: '{text_part[:50]}...'", file=sys.stderr) 232 | elif isinstance(content, str): 233 | system_message += content + "\n" 234 | print(f"[DEBUG] Added string content: '{content[:100]}...'", file=sys.stderr) 235 | if not found: 236 | print("[DEBUG] No system message found in conversation.", file=sys.stderr) 237 | final_message = system_message.strip() 238 | print(f"[DEBUG] Final extracted system message: '{final_message[:100]}...'", file=sys.stderr) 239 | print("[DEBUG] --- Exiting extract_system_message ---", file=sys.stderr) 240 | return final_message 241 | 242 | def map_json_type_to_python_str(json_type: str) -> str: 243 | """Maps JSON schema types to Python type hint strings for docstrings.""" 244 | # No logging needed here, it's a simple mapping function. 245 | type_map = { 246 | "string": "str", 247 | "integer": "int", 248 | "number": "float", # Using float for number 249 | "boolean": "bool", 250 | "array": "list", 251 | "object": "dict", 252 | } 253 | py_type = type_map.get(json_type, "Any") 254 | # print(f"[DEBUG] Mapped JSON type '{json_type}' to Python type string '{py_type}'", file=sys.stderr) # Optional: uncomment if needed 255 | return py_type 256 | 257 | def create_python_function_standard_docstring(func_def: Dict[str, Any], tool_calls: List[Dict[str, Any]]) -> Optional[Callable]: 258 | """ 259 | Create a Python function wrapper using standard parameters and docstring format. 260 | 261 | Args: 262 | func_def: The MCP function definition (includes 'name', 'description', 'parameters') 263 | tool_calls: A list to store captured tool calls for Dolphin MCP 264 | 265 | Returns: 266 | A callable Python function object or None if creation failed 267 | """ 268 | full_name = func_def.get("name", "unknown_tool") # Get name early for logging 269 | print(f"[DEBUG] --- Entering create_python_function_standard_docstring for: {full_name} ---", file=sys.stderr) 270 | try: 271 | print(f"[DEBUG] Processing func_def: {json.dumps(func_def, indent=2)}", file=sys.stderr) 272 | 273 | name_parts = full_name.split("_", 1) 274 | if len(name_parts) < 2: 275 | print(f"[ERROR] Invalid function name format for '{full_name}'. Expected 'server_tool'.", file=sys.stderr) 276 | print(f"[DEBUG] --- Exiting create_python_function_standard_docstring (invalid name format) ---", file=sys.stderr) 277 | return None 278 | 279 | server_name = name_parts[0] 280 | function_name = name_parts[1] # Simple name for LMStudio 281 | print(f"[DEBUG] Parsed name -> LMStudio name: '{function_name}', Server: '{server_name}'", file=sys.stderr) 282 | 283 | description = func_def.get("description", "") 284 | parameters = func_def.get("parameters", {}) 285 | properties = parameters.get("properties", {}) 286 | required = parameters.get("required", []) 287 | print(f"[DEBUG] Extracted description: '{description[:50]}...'", file=sys.stderr) 288 | print(f"[DEBUG] Extracted properties: {list(properties.keys())}", file=sys.stderr) 289 | print(f"[DEBUG] Required parameters: {required}", file=sys.stderr) 290 | 291 | # Build a standard Python docstring 292 | print("[DEBUG] Building docstring...", file=sys.stderr) 293 | docstring_parts = [f"{description}\n"] # Description first 294 | if properties: 295 | docstring_parts.append("Args:") # Standard 'Args:' section 296 | for param_name, param_info in properties.items(): 297 | param_type_json = param_info.get("type", "any") 298 | param_type_py_str = map_json_type_to_python_str(param_type_json) 299 | param_desc = param_info.get("description", "") 300 | # Format: param_name (type): description. (Required/Optional) 301 | required_str = "Required." if param_name in required else "Optional." 302 | docstring_parts.append(f" {param_name} ({param_type_py_str}): {param_desc} {required_str}") 303 | docstring = "\n".join(docstring_parts) 304 | print(f"[DEBUG] Built docstring for {function_name}:\n{docstring}", file=sys.stderr) 305 | 306 | # Generate parameter list with type hints from JSON schema 307 | print("[DEBUG] Building parameter string for function definition...", file=sys.stderr) 308 | param_list = [] 309 | for param_name, param_info in properties.items(): 310 | py_type = map_json_type_to_python_str(param_info.get("type", "any")) 311 | if param_name in required: 312 | param_list.append(f"{param_name}: {py_type}") 313 | else: 314 | # Determine a sensible default based on type for the signature 315 | default_value_str = "None" # Default to None for optional non-strings 316 | if param_info.get("type") == "string": 317 | default_value_str = '""' 318 | elif param_info.get("type") == "boolean": 319 | default_value_str = "False" # Or True? False seems safer. 320 | elif param_info.get("type") == "integer" or param_info.get("type") == "number": 321 | default_value_str = "0" # Or None? 0 might be okay. 322 | elif param_info.get("type") == "array": 323 | default_value_str = "[]" 324 | elif param_info.get("type") == "object": 325 | default_value_str = "{}" 326 | 327 | param_list.append(f"{param_name}: Optional[{py_type}] = {default_value_str}") # Use Optional for clarity 328 | param_str = ", ".join(param_list) 329 | print(f"[DEBUG] Built parameter string: '{param_str}'", file=sys.stderr) 330 | 331 | param_names = list(properties.keys()) # Get list of expected parameter names 332 | print(f"[DEBUG] List of parameter names for wrapper: {param_names}", file=sys.stderr) 333 | 334 | # Dynamically compile the function with proper syntax 335 | # Ensure only necessary variables are passed to exec_scope 336 | exec_scope = { 337 | 'tool_calls': tool_calls, 338 | 'json': json, 339 | 'function_name': function_name, # Simple name for logging inside wrapper 340 | 'full_name': full_name, # Full MCP name for tool_call object 341 | # 'logger': logger, # Removed logger from scope 342 | 'param_names': param_names, 343 | 'traceback': traceback, # Pass traceback module for logging 344 | 'Optional': Optional, # Make Optional available for type hints 345 | 'sys': sys # Make sys available for print 346 | } 347 | print(f"[DEBUG] Preparing execution scope for exec(). Keys: {list(exec_scope.keys())}", file=sys.stderr) 348 | 349 | # More robust code string using locals() and explicit parameter names 350 | # Includes more logging within the generated function 351 | code_string = f''' 352 | import json # Ensure json is available inside the function too 353 | import traceback # Ensure traceback is available 354 | from typing import Optional # Ensure Optional is available 355 | import sys # For print to stderr 356 | 357 | # Define the function with the generated parameter string 358 | def tool_function_wrapper({param_str}): 359 | """Dynamically created wrapper for LMStudio tool use: {function_name}.""" 360 | print(f"[DEBUG] --- Entering LMStudio wrapper: {{function_name}} ---", file=sys.stderr) 361 | args_dict = {{}} # Initialize args_dict for logging/tool_call 362 | try: 363 | # Capture arguments passed to the function using locals() 364 | local_vars = locals() 365 | print(f"[DEBUG] Wrapper {{function_name}} locals(): {{local_vars}}", file=sys.stderr) 366 | 367 | # Construct args_dict *only* from expected parameters defined in param_names 368 | # Filter out None values unless explicitly needed? For now, include them. 369 | args_dict = {{k: local_vars[k] for k in param_names if k in local_vars}} 370 | print(f"[DEBUG] Constructed args_dict for {{function_name}}: {{args_dict}}", file=sys.stderr) 371 | 372 | # Generate a unique-ish call ID 373 | # Using hash of sorted JSON args is reasonably stable 374 | try: 375 | args_json_str = json.dumps(args_dict, sort_keys=True) 376 | call_id_suffix = abs(hash(args_json_str)) 377 | except TypeError: # Handle non-serializable args if they somehow occur 378 | print(f"[WARN] Could not serialize args for call_id generation in {{function_name}}. Using fallback.", file=sys.stderr) 379 | call_id_suffix = "fallback" 380 | call_id = f"call_{{full_name}}_{{call_id_suffix}}" 381 | print(f"[DEBUG] Generated call_id: {{call_id}}", file=sys.stderr) 382 | 383 | # Create the tool_call dictionary for Dolphin MCP 384 | tool_call = {{ 385 | "id": call_id, 386 | "function": {{ 387 | "name": full_name, # Use the full MCP name (server_tool) 388 | "arguments": json.dumps(args_dict) # Arguments as JSON string 389 | }} 390 | }} 391 | print(f"[DEBUG] Created tool_call object for {{full_name}}: {{tool_call}}", file=sys.stderr) 392 | 393 | # Safely append to tool_calls list (passed via exec_scope) 394 | # Ensure tool_calls is treated as a list 395 | if isinstance(tool_calls, list): 396 | tool_calls.append(tool_call) 397 | print(f"[INFO] Appended tool call for {{full_name}} to shared list.", file=sys.stderr) 398 | else: 399 | print(f"[ERROR] CRITICAL: tool_calls object in wrapper {{function_name}} is not a list! Type: {{type(tool_calls)}}", file=sys.stderr) 400 | 401 | 402 | # Return a confirmation message (LMStudio expects a string response from tools) 403 | result_msg = f"[Tool call {{function_name}} captured by wrapper]" 404 | print(f"[DEBUG] --- Exiting LMStudio wrapper {{function_name}} (success) ---", file=sys.stderr) 405 | return result_msg 406 | 407 | except Exception as e: 408 | # More detailed error logging inside the wrapper 409 | print(f"[ERROR] Exception inside LMStudio tool wrapper {{function_name}} for {{full_name}}: {{e}}", file=sys.stderr) 410 | print(f"[DEBUG] Arguments during exception: {{args_dict}}", file=sys.stderr) 411 | # Use traceback passed in scope 412 | print(f"[DEBUG] {traceback.format_exc()}", file=sys.stderr) 413 | error_msg = f"[Error capturing tool call for {{function_name}}: {{e}}]" 414 | print(f"[DEBUG] --- Exiting LMStudio wrapper {{function_name}} (exception) ---", file=sys.stderr) 415 | return error_msg # Return error message string 416 | ''' 417 | print(f"[DEBUG] Code string to be executed for {function_name}:\n{code_string[:500]}...", file=sys.stderr) # Log start of code string 418 | 419 | try: 420 | exec(code_string, exec_scope) 421 | print(f"[DEBUG] Exec completed successfully for {function_name}", file=sys.stderr) 422 | except Exception as exec_error: 423 | # Catch errors during the exec call itself (e.g., syntax errors in generated code) 424 | print(f"[ERROR] CRITICAL: Failed to exec function definition for {function_name}: {exec_error}", file=sys.stderr) 425 | print(f"[DEBUG] Code string attempted:\n{code_string}", file=sys.stderr) 426 | print(f"[DEBUG] {traceback.format_exc()}", file=sys.stderr) 427 | print(f"[DEBUG] --- Exiting create_python_function_standard_docstring (exec error) ---", file=sys.stderr) 428 | return None # Fail creation if exec fails 429 | 430 | # Retrieve the dynamically created function from the execution scope 431 | tool_function_wrapper = exec_scope.get('tool_function_wrapper') # Use .get() for safety 432 | if tool_function_wrapper is None: 433 | print(f"[ERROR] Failed to retrieve 'tool_function_wrapper' from exec_scope after exec for {function_name}", file=sys.stderr) 434 | print(f"[DEBUG] --- Exiting create_python_function_standard_docstring (retrieval failed) ---", file=sys.stderr) 435 | return None 436 | print(f"[DEBUG] Retrieved function object for {function_name}: {tool_function_wrapper}", file=sys.stderr) 437 | 438 | # Set the essential metadata: name and the standard docstring 439 | tool_function_wrapper.__name__ = function_name # Use the simple name for LMStudio 440 | tool_function_wrapper.__doc__ = docstring 441 | print(f"[DEBUG] Set __name__='{function_name}' and __doc__ for wrapper.", file=sys.stderr) 442 | 443 | print(f"[INFO] Successfully created Python function wrapper for {full_name} (LMStudio name: {function_name})", file=sys.stderr) 444 | print(f"[DEBUG] --- Exiting create_python_function_standard_docstring (success) ---", file=sys.stderr) 445 | return tool_function_wrapper 446 | 447 | except Exception as e: 448 | print(f"[ERROR] Unhandled exception in create_python_function_standard_docstring for {full_name}: {e}", file=sys.stderr) 449 | print(f"[DEBUG] {traceback.format_exc()}", file=sys.stderr) 450 | print(f"[DEBUG] --- Exiting create_python_function_standard_docstring (unhandled exception) ---", file=sys.stderr) 451 | return None # Logged failure implicitly by returning None 452 | -------------------------------------------------------------------------------- /src/dolphin_mcp/providers/msazureopenai.py: -------------------------------------------------------------------------------- 1 | # msazure.py 2 | 3 | import os 4 | import json 5 | from typing import Dict, List, Any, AsyncGenerator, Optional, Union 6 | import aiohttp 7 | from dotenv import load_dotenv 8 | 9 | 10 | def load_env(): 11 | """Load environment variables from .env file""" 12 | load_dotenv() 13 | 14 | required_keys = [ 15 | "AZURE_OPENAI_API_KEY", 16 | "AZURE_OPENAI_API_ENDPOINT", 17 | "AZURE_OPENAI_DEPLOYMENT_ID", 18 | "AZURE_OPENAI_API_VERSION" 19 | ] 20 | for key in required_keys: 21 | if key not in os.environ: 22 | raise ValueError(f"Required environment variable {key} is not set.") 23 | 24 | async def generate_with_msazure_openai_stream(model_cfg: Dict, conversation: List[Dict], 25 | formatted_functions: List[Dict], 26 | temperature: Optional[float] = None, 27 | top_p: Optional[float] = None, 28 | max_tokens: Optional[int] = None) -> AsyncGenerator: 29 | """Streaming generation with Azure OpenAI""" 30 | api_key = os.environ.get("AZURE_OPENAI_API_KEY") 31 | api_base = os.environ.get("AZURE_OPENAI_API_ENDPOINT") 32 | if api_base is None: 33 | raise ValueError("AZURE_OPENAI_API_ENDPOINT environment variable is not set.") 34 | deployment_id = os.environ.get("AZURE_OPENAI_DEPLOYMENT_ID") 35 | api_version = os.environ.get("AZURE_OPENAI_API_VERSION") 36 | 37 | url = f"{api_base}/openai/deployments/{deployment_id}/chat/completions?api-version={api_version}" 38 | headers = { 39 | "Content-Type": "application/json", 40 | "api-key": api_key 41 | } 42 | payload = { 43 | "messages": conversation, 44 | "temperature": temperature, 45 | "top_p": top_p, 46 | "max_tokens": max_tokens, 47 | "tools": [{"type": "function", "function": f} for f in formatted_functions], 48 | "tool_choice": "auto", 49 | "stream": True 50 | } 51 | 52 | async with aiohttp.ClientSession() as session: 53 | async with session.post(url, headers=headers, json=payload) as response: 54 | if response.status != 200: 55 | error_text = await response.text() 56 | yield {"assistant_text": f"Azure OpenAI API error: {error_text}", "tool_calls": [], "is_chunk": False} 57 | return 58 | 59 | async for line in response.content: 60 | if line: 61 | decoded_line = line.decode('utf-8').strip() 62 | if decoded_line.startswith("data: "): 63 | data = decoded_line[6:] 64 | if data == "[DONE]": 65 | break 66 | chunk = json.loads(data) 67 | delta = chunk["choices"][0]["delta"] 68 | content = delta.get("content", "") 69 | if content: 70 | yield {"assistant_text": content, "tool_calls": [], "is_chunk": True, "token": True} 71 | 72 | async def generate_with_msazure_openai_sync(model_cfg: Dict, conversation: List[Dict], 73 | formatted_functions: List[Dict], 74 | temperature: Optional[float] = None, 75 | top_p: Optional[float] = None, 76 | max_tokens: Optional[int] = None) -> Dict: 77 | """Non-streaming generation with Azure OpenAI""" 78 | api_key = os.environ.get("AZURE_OPENAI_API_KEY") 79 | api_base = os.environ.get("AZURE_OPENAI_API_ENDPOINT") 80 | if api_base is None: 81 | raise ValueError("AZURE_OPENAI_API_ENDPOINT environment variable is not set.") 82 | deployment_id = os.environ.get("AZURE_OPENAI_DEPLOYMENT_ID") 83 | api_version = os.environ.get("AZURE_OPENAI_API_VERSION") 84 | 85 | url = f"{api_base}/openai/deployments/{deployment_id}/chat/completions?api-version={api_version}" 86 | 87 | headers = { 88 | "Content-Type": "application/json", 89 | "api-key": api_key 90 | } 91 | payload = { 92 | "messages": conversation, 93 | "temperature": temperature, 94 | "top_p": top_p, 95 | "max_tokens": max_tokens, 96 | "tools": [{"type": "function", "function": f} for f in formatted_functions], 97 | "tool_choice": "auto", 98 | "stream": False 99 | } 100 | 101 | async with aiohttp.ClientSession() as session: 102 | async with session.post(url, headers=headers, json=payload) as response: 103 | if response.status != 200: 104 | error_text = await response.text() 105 | return {"assistant_text": f"Azure OpenAI API error: {error_text}", "tool_calls": []} 106 | data = await response.json() 107 | choice = data["choices"][0] 108 | assistant_text = choice["message"].get("content", "") 109 | tool_calls = choice["message"].get("tool_calls", []) 110 | return {"assistant_text": assistant_text, "tool_calls": tool_calls} 111 | 112 | async def generate_with_msazure_openai(conversation: List[Dict], model_cfg: Dict, 113 | all_functions: List[Dict], stream: bool = False) -> Union[Dict, AsyncGenerator]: 114 | """Dispatcher for Azure OpenAI generation""" 115 | temperature = model_cfg.get("temperature", 0.7) 116 | top_p = model_cfg.get("top_p", 0.95) 117 | max_tokens = model_cfg.get("max_tokens", 1000) 118 | 119 | formatted_functions = [ 120 | { 121 | "name": func["name"], 122 | "description": func["description"], 123 | "parameters": func["parameters"] 124 | } for func in all_functions 125 | ] 126 | 127 | if stream: 128 | return generate_with_msazure_openai_stream( 129 | model_cfg, conversation, formatted_functions, 130 | temperature, top_p, max_tokens 131 | ) 132 | else: 133 | return await generate_with_msazure_openai_sync( 134 | model_cfg, conversation, formatted_functions, 135 | temperature, top_p, max_tokens 136 | ) 137 | 138 | -------------------------------------------------------------------------------- /src/dolphin_mcp/providers/ollama.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ollama provider implementation for Dolphin MCP. 3 | 4 | This module provides integration with Ollama models for text generation and tool usage, 5 | including proper formatting of tool calls and their arguments. 6 | """ 7 | 8 | import json 9 | import logging 10 | import sys 11 | import traceback 12 | import copy 13 | from typing import Dict, List, Any, Optional, Union, Mapping, TypeVar, cast, Callable 14 | 15 | # Third-party imports 16 | import httpx 17 | from pydantic import BaseModel, ValidationError 18 | # Import Ollama types for response parsing 19 | from ollama import ResponseError 20 | from ollama._types import ChatResponse, Message 21 | 22 | # Configure logging 23 | logging.basicConfig( 24 | level=logging.DEBUG, 25 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 26 | handlers=[logging.StreamHandler()] 27 | ) 28 | logger = logging.getLogger('dolphin_mcp.providers.ollama') 29 | 30 | # Constants 31 | DEFAULT_API_HOST = "http://localhost:11434" 32 | DEFAULT_TEMPERATURE = 0.7 33 | DEFAULT_MAX_TOKENS = 1024 34 | EMPTY_JSON_VALUES = ('', '{}') 35 | 36 | # Type definitions 37 | JsonDict = Dict[str, Any] 38 | OllamaToolType = Dict[str, Any] 39 | MessageType = Dict[str, Any] 40 | T = TypeVar('T') 41 | 42 | # Global mapping to track original tool names 43 | tool_name_mapping: Dict[str, str] = {} 44 | 45 | 46 | def sanitize_tool_name(name: str) -> str: 47 | """ 48 | Sanitize tool name for compatibility with various systems. 49 | 50 | Args: 51 | name: The original tool name 52 | 53 | Returns: 54 | A sanitized version of the name with problematic characters replaced 55 | """ 56 | return name.replace("-", "_").replace(" ", "_").lower() 57 | 58 | 59 | def parse_json_safely(json_str: Union[str, Any]) -> JsonDict: 60 | """ 61 | Parse a JSON string safely, handling edge cases and returning an empty dict for invalid inputs. 62 | 63 | Args: 64 | json_str: String containing JSON or any other value 65 | 66 | Returns: 67 | Parsed JSON dict or empty dict if parsing fails 68 | """ 69 | # Handle non-string inputs 70 | if not isinstance(json_str, str): 71 | return {} 72 | 73 | # Handle empty inputs 74 | json_str = json_str.strip() 75 | if not json_str or json_str in EMPTY_JSON_VALUES: 76 | return {} 77 | 78 | # Attempt to parse 79 | try: 80 | return json.loads(json_str) 81 | except json.JSONDecodeError: 82 | logger.warning(f"Failed to parse JSON string: {json_str}") 83 | return {} 84 | 85 | 86 | def preprocess_messages(messages: List[MessageType]) -> List[MessageType]: 87 | """ 88 | Preprocess conversation messages to ensure tool_calls.function.arguments are dictionaries. 89 | The Ollama server expects arguments as objects, not strings. 90 | 91 | Args: 92 | messages: List of message objects from the conversation history 93 | 94 | Returns: 95 | Processed copy of messages with tool call arguments converted to dictionaries 96 | """ 97 | if not messages: 98 | return messages 99 | 100 | # Create a deep copy to avoid modifying the original 101 | msgs_copy = copy.deepcopy(messages) 102 | modified_count = 0 103 | 104 | for msg in msgs_copy: 105 | # Check if the message has tool_calls 106 | if isinstance(msg, dict) and 'tool_calls' in msg and msg['tool_calls']: 107 | for tool_call in msg['tool_calls']: 108 | if isinstance(tool_call, dict) and 'function' in tool_call: 109 | if 'arguments' in tool_call['function']: 110 | # If arguments is a string, parse it to a dict 111 | if isinstance(tool_call['function']['arguments'], str): 112 | try: 113 | parsed = parse_json_safely(tool_call['function']['arguments']) 114 | # If parsing results in an empty dict, remove the key instead 115 | if not parsed: 116 | del tool_call['function']['arguments'] 117 | logger.debug("Removed empty arguments key during preprocessing.") 118 | else: 119 | tool_call['function']['arguments'] = parsed 120 | modified_count += 1 121 | except Exception as e: 122 | logger.error(f"Error parsing tool call arguments: {e}") 123 | # If error, ensure key is removed or set to empty dict 124 | if 'arguments' in tool_call['function']: 125 | del tool_call['function']['arguments'] 126 | 127 | if modified_count > 0: 128 | logger.debug(f"Preprocessed {modified_count} tool call arguments from strings to dicts") 129 | 130 | return msgs_copy 131 | 132 | 133 | def convert_mcp_tools_to_ollama_format(mcp_tools: Union[List[Any], Dict[str, Any], Any]) -> List[OllamaToolType]: 134 | """ 135 | Convert MCP tool format to Ollama tool format according to the Ollama SDK docs. 136 | 137 | Args: 138 | mcp_tools: Tools in MCP format (list, dict with 'tools' key, or object with 'tools' attribute) 139 | 140 | Returns: 141 | List of tools formatted for Ollama's API 142 | """ 143 | logger.debug("Converting MCP tools to Ollama format") 144 | ollama_tools = [] 145 | 146 | # Clear the global mapping before processing 147 | tool_name_mapping.clear() 148 | 149 | # Extract tools from the input based on its type 150 | tools_list = extract_tools_list(mcp_tools) 151 | 152 | # Process each tool in the list 153 | if isinstance(tools_list, list): 154 | for tool_idx, tool in enumerate(tools_list): 155 | if "name" in tool and "description" in tool: 156 | # Process valid tool 157 | ollama_tool = process_single_tool(tool, tool_idx) 158 | if ollama_tool: 159 | ollama_tools.append(ollama_tool) 160 | else: 161 | logger.warning(f"Tool missing required attributes: has name = {'name' in tool}, has description = {'description' in tool}") 162 | else: 163 | logger.warning(f"Tools list is not a list, it's a {type(tools_list)}") 164 | 165 | logger.debug(f"Converted {len(ollama_tools)} tools to Ollama format") 166 | return ollama_tools 167 | 168 | 169 | def extract_tools_list(mcp_tools: Union[List[Any], Dict[str, Any], Any]) -> List[Any]: 170 | """ 171 | Extract the actual tools list from various possible input formats. 172 | 173 | Args: 174 | mcp_tools: The input that contains tools in some format 175 | 176 | Returns: 177 | List of tools 178 | """ 179 | if hasattr(mcp_tools, 'tools'): 180 | tools_list = mcp_tools.tools 181 | logger.debug("Extracted tools from object.tools attribute") 182 | elif isinstance(mcp_tools, dict): 183 | tools_list = mcp_tools.get('tools', []) 184 | logger.debug("Extracted tools from dict['tools']") 185 | else: 186 | tools_list = mcp_tools 187 | logger.debug(f"Using tools directly from input of type {type(mcp_tools)}") 188 | 189 | return tools_list 190 | 191 | 192 | def process_single_tool(tool: Dict[str, Any], tool_idx: int) -> Optional[OllamaToolType]: 193 | """ 194 | Process a single tool definition into Ollama format. 195 | 196 | Args: 197 | tool: Tool definition 198 | tool_idx: Index for logging purposes 199 | 200 | Returns: 201 | Tool in Ollama format or None if processing fails 202 | """ 203 | try: 204 | # Store the original name in our mapping 205 | original_name = tool["name"] 206 | logger.debug(f"Processing tool [{tool_idx}]: {original_name}") 207 | 208 | # For server_name_tool_name format used in client.py 209 | server_tool_name = f"{original_name}" 210 | tool_name_mapping[original_name] = server_tool_name 211 | 212 | # Get parameter properties 213 | properties, required = extract_tool_parameters(tool) 214 | 215 | # Create tool in Ollama's expected format based on docs 216 | ollama_tool = { 217 | "type": "function", 218 | "function": { 219 | "name": original_name, 220 | "description": tool.get("description", ""), 221 | "parameters": { 222 | "type": "object", 223 | "properties": properties, 224 | "required": required 225 | } 226 | } 227 | } 228 | 229 | logger.debug(f"Added tool to Ollama format: {original_name}") 230 | return ollama_tool 231 | 232 | except Exception as e: 233 | logger.error(f"Error processing tool: {e}") 234 | return None 235 | 236 | 237 | def extract_tool_parameters(tool: Dict[str, Any]) -> tuple[dict, list]: 238 | """ 239 | Extract parameter properties and required fields from a tool definition. 240 | 241 | Args: 242 | tool: Tool definition 243 | 244 | Returns: 245 | Tuple of (properties dict, required list) 246 | """ 247 | properties = {} 248 | required = [] 249 | 250 | if "parameters" in tool: 251 | if isinstance(tool["parameters"], dict): 252 | properties = tool["parameters"].get("properties", {}) 253 | required = tool["parameters"].get("required", []) 254 | logger.debug(f"Tool has parameters: properties={list(properties.keys())}, required={required}") 255 | else: 256 | logger.warning(f"Tool parameters not a dict: {type(tool['parameters'])}") 257 | else: 258 | logger.debug("Tool has no parameters defined") 259 | 260 | return properties, required 261 | 262 | 263 | def prepare_ollama_options(model_cfg: Dict[str, Any]) -> tuple[Dict[str, Any], Optional[Any], str]: 264 | """ 265 | Prepare options for Ollama API call. 266 | 267 | Args: 268 | model_cfg: Model configuration 269 | 270 | Returns: 271 | Tuple of (options dict, client object, keep_alive value) 272 | """ 273 | # Import here to avoid import errors when module is loaded 274 | from ollama import Client 275 | 276 | options = {} 277 | client = None 278 | keep_alive_seconds = "0" 279 | 280 | # Set model parameters from config 281 | if "temperature" in model_cfg: 282 | options["temperature"] = model_cfg.get("temperature", DEFAULT_TEMPERATURE) 283 | if "top_k" in model_cfg: 284 | options["top_k"] = model_cfg.get("top_k") 285 | if "repetition_penalty" in model_cfg: 286 | options["repeat_penalty"] = model_cfg.get("repetition_penalty") 287 | if "max_tokens" in model_cfg: 288 | options["num_predict"] = model_cfg.get("max_tokens", DEFAULT_MAX_TOKENS) 289 | if "client" in model_cfg: 290 | client = Client(host=model_cfg.get("client", DEFAULT_API_HOST)) 291 | if "keep_alive_seconds" in model_cfg: 292 | keep_alive_seconds = model_cfg.get("keep_alive_seconds") + "s" 293 | 294 | return options, client, keep_alive_seconds 295 | 296 | 297 | def format_tool_calls(response_tool_calls: List[Any]) -> List[Dict[str, Any]]: 298 | """ 299 | Format tool calls from Ollama response for Dolphin MCP. 300 | 301 | Args: 302 | response_tool_calls: Tool calls from Ollama response 303 | 304 | Returns: 305 | Formatted tool calls for client.py 306 | """ 307 | tool_calls = [] 308 | 309 | for i, tool in enumerate(response_tool_calls): 310 | # Get function details 311 | func_name = tool.function.name 312 | func_args = {} 313 | 314 | # Extract arguments (should be a dict in the response) 315 | if hasattr(tool.function, 'arguments'): 316 | if isinstance(tool.function.arguments, dict): 317 | func_args = tool.function.arguments 318 | logger.debug(f"Tool {i} arguments: {func_args}") 319 | else: 320 | # If somehow not a dict, try to convert 321 | if isinstance(tool.function.arguments, str): 322 | func_args = parse_json_safely(tool.function.arguments) 323 | logger.debug(f"Converted string arguments to dict: {func_args}") 324 | 325 | # Format the function name for client.py 326 | formatted_name = format_function_name(func_name) 327 | 328 | # Convert arguments back to a JSON string for client.py 329 | args_str = json.dumps(func_args) if func_args else "{}" 330 | 331 | # Create the tool object in the format expected by client.py 332 | tool_obj = { 333 | "id": f"call_ollama_{i}", 334 | "function": { 335 | "name": formatted_name, 336 | "arguments": args_str # Must be string for client.py 337 | } 338 | } 339 | 340 | tool_calls.append(tool_obj) 341 | logger.debug(f"Added tool call: {formatted_name}") 342 | 343 | return tool_calls 344 | 345 | 346 | def format_function_name(func_name: str) -> str: 347 | """ 348 | Format function name with server prefix if needed. 349 | 350 | Args: 351 | func_name: Original function name 352 | 353 | Returns: 354 | Formatted function name 355 | """ 356 | formatted_name = func_name 357 | if "_" not in func_name and tool_name_mapping: 358 | first_server_prefix = next(iter(tool_name_mapping.values()), "unknown_server") 359 | formatted_name = f"{first_server_prefix}_{func_name}" 360 | logger.debug(f"Formatted name: {formatted_name}") 361 | 362 | return formatted_name 363 | 364 | 365 | async def generate_with_ollama( 366 | conversation: List[MessageType], 367 | model_cfg: Dict[str, Any], 368 | all_functions: Union[List[Any], Dict[str, Any], Any] 369 | ) -> Dict[str, Any]: 370 | """ 371 | Generate text using Ollama's API. 372 | 373 | Args: 374 | conversation: The conversation history as a list of message objects 375 | model_cfg: Configuration for the model including parameters and options 376 | all_functions: Available functions for the model to call 377 | 378 | Returns: 379 | Dict containing assistant_text and tool_calls 380 | """ 381 | logger.debug("===== Starting generate_with_ollama =====") 382 | 383 | # Import required components from Ollama 384 | try: 385 | ollama_imports = import_ollama_components() 386 | if not ollama_imports: 387 | return {"assistant_text": "Failed to import required Ollama components", "tool_calls": []} 388 | chat, Client, ResponseError = ollama_imports 389 | except Exception as e: 390 | logger.error(f"Unexpected error during Ollama import: {e}") 391 | return {"assistant_text": f"Unexpected Ollama import error: {str(e)}", "tool_calls": []} 392 | 393 | # Get model name from config 394 | model_name = model_cfg.get("model", "") 395 | if not model_name: 396 | error_msg = "Model name is required but was not provided in configuration" 397 | logger.error(error_msg) 398 | return {"assistant_text": error_msg, "tool_calls": []} 399 | 400 | logger.debug(f"Using model: {model_name}") 401 | 402 | # Convert tools to Ollama format 403 | converted_all_functions = convert_mcp_tools_to_ollama_format(all_functions) 404 | 405 | # Prepare options dictionary for Ollama 406 | options, client, keep_alive_seconds = prepare_ollama_options(model_cfg) 407 | 408 | # Preprocess conversation messages to ensure arguments are dictionaries 409 | processed_conversation = preprocess_messages(conversation) 410 | 411 | # Define a baseline chat params dict 412 | chat_params = { 413 | "model": model_name, 414 | "messages": processed_conversation, 415 | "options": options, 416 | "stream": False, 417 | "tools": converted_all_functions 418 | } 419 | 420 | # Add keep_alive if needed 421 | if keep_alive_seconds != "0": 422 | chat_params["keep_alive"] = keep_alive_seconds 423 | 424 | logger.debug(f"Chat parameters prepared with {len(converted_all_functions)} tools") 425 | 426 | # Log conversation for debugging (abbreviated) 427 | log_conversation_sample(processed_conversation) 428 | 429 | # Call Ollama API 430 | try: 431 | # Make the API call 432 | response = await call_ollama_api(chat, client, chat_params) 433 | if isinstance(response, dict) and "assistant_text" in response: 434 | # This is an error response from call_ollama_api 435 | return response 436 | 437 | # Extract assistant text 438 | assistant_text = response.message.content or "" 439 | logger.debug(f"Assistant text (abbreviated): {assistant_text[:100]}...") 440 | 441 | # Process tool calls if present 442 | tool_calls = [] 443 | if hasattr(response.message, 'tool_calls') and response.message.tool_calls: 444 | logger.debug(f"Found {len(response.message.tool_calls)} tool calls in response") 445 | tool_calls = format_tool_calls(response.message.tool_calls) 446 | 447 | return {"assistant_text": assistant_text, "tool_calls": tool_calls} 448 | 449 | except Exception as e: 450 | logger.error(f"Unexpected error in generate_with_ollama: {e}") 451 | traceback.print_exc() 452 | return {"assistant_text": f"Unexpected error: {str(e)}", "tool_calls": []} 453 | 454 | 455 | def import_ollama_components() -> Optional[tuple]: 456 | """ 457 | Import the necessary Ollama components. 458 | 459 | Returns: 460 | Tuple of (chat, Client, ResponseError) or None if import fails 461 | """ 462 | try: 463 | from ollama import chat, Client, ResponseError 464 | logger.debug("Imported Ollama SDK successfully") 465 | 466 | # Try to get the version if available 467 | try: 468 | import importlib.metadata 469 | ollama_version = importlib.metadata.version('ollama') 470 | logger.debug(f"Ollama SDK version: {ollama_version}") 471 | except (ImportError, importlib.metadata.PackageNotFoundError): 472 | logger.debug("Could not determine Ollama SDK version") 473 | 474 | return chat, Client, ResponseError 475 | except ImportError as e: 476 | logger.error(f"Failed to import Ollama SDK: {e}") 477 | return None 478 | 479 | 480 | def log_conversation_sample(conversation: List[MessageType]) -> None: 481 | """ 482 | Log a sample of the conversation for debugging. 483 | 484 | Args: 485 | conversation: The conversation to log 486 | """ 487 | if not conversation: 488 | return 489 | 490 | try: 491 | if len(conversation) > 0: 492 | first_msg = json.dumps(conversation[0])[:150] 493 | logger.debug(f"First message (abbreviated): {first_msg}...") 494 | 495 | if len(conversation) > 1: 496 | last_msg = json.dumps(conversation[-1])[:150] 497 | logger.debug(f"Last message (abbreviated): {last_msg}...") 498 | except Exception as e: 499 | logger.debug(f"Could not log conversation sample: {e}") 500 | 501 | 502 | async def call_ollama_api( 503 | chat: Callable, 504 | client: Optional[Any], 505 | chat_params: Dict[str, Any] 506 | ) -> Union[ChatResponse, Dict[str, Any]]: 507 | """ 508 | Call the Ollama API using httpx to manually handle the response and fix potential issues 509 | before Pydantic validation. 510 | 511 | Args: 512 | chat: The chat function from ollama (unused, kept for signature compatibility for now) 513 | client: Optional ollama client object to get host info 514 | chat_params: Parameters for the chat call 515 | 516 | Returns: 517 | Either a parsed ChatResponse object or an error dict 518 | """ 519 | logger.debug("Calling Ollama API via httpx...") 520 | 521 | # Determine host and construct URL 522 | host = DEFAULT_API_HOST 523 | if client and hasattr(client, 'host'): 524 | host = client.host 525 | api_url = f"{host}/api/chat" 526 | logger.debug(f"Target API URL: {api_url}") 527 | 528 | try: 529 | async with httpx.AsyncClient(timeout=600.0) as http_client: # Increased timeout 530 | http_response = await http_client.post(api_url, json=chat_params) 531 | http_response.raise_for_status() # Raise exception for 4xx/5xx errors 532 | 533 | raw_response_data = http_response.json() 534 | logger.debug("Received raw JSON response from Ollama API.") 535 | 536 | # --- Manual Fix for Missing 'arguments' --- 537 | if 'message' in raw_response_data and 'tool_calls' in raw_response_data['message'] and raw_response_data['message']['tool_calls']: 538 | corrected_count = 0 539 | for tool_call in raw_response_data['message']['tool_calls']: 540 | if isinstance(tool_call, dict) and 'function' in tool_call: 541 | if 'arguments' not in tool_call['function']: 542 | tool_call['function']['arguments'] = {} 543 | corrected_count += 1 544 | logger.debug(f"Manually added missing 'arguments: {{}}' to tool call: {tool_call.get('function', {}).get('name')}") 545 | if corrected_count > 0: 546 | logger.info(f"Corrected {corrected_count} tool calls with missing 'arguments'.") 547 | # --- End Manual Fix --- 548 | 549 | # Attempt to parse the corrected data using the SDK's Pydantic model 550 | try: 551 | parsed_response = ChatResponse(**raw_response_data) 552 | logger.debug("Successfully parsed corrected JSON into ChatResponse model.") 553 | return parsed_response 554 | except ValidationError as pydantic_error: 555 | logger.error(f"Pydantic validation failed even after manual correction: {pydantic_error}") 556 | logger.debug(f"Corrected JSON data that failed validation: {raw_response_data}") 557 | return { 558 | "assistant_text": f"Ollama SDK Validation Error after manual correction: {str(pydantic_error)}", 559 | "tool_calls": [] 560 | } 561 | 562 | except httpx.HTTPStatusError as e: 563 | logger.error(f"Ollama API HTTP Error: {e.response.status_code} - {e.response.text}") 564 | # Try to parse error details from response if possible 565 | error_detail = e.response.text 566 | try: 567 | error_json = e.response.json() 568 | error_detail = error_json.get('error', error_detail) 569 | except Exception: 570 | pass # Keep original text if JSON parsing fails 571 | return {"assistant_text": f"Ollama API HTTP Error {e.response.status_code}: {error_detail}", "tool_calls": []} 572 | except httpx.RequestError as e: 573 | logger.error(f"Ollama API Request Error: {e}") 574 | return {"assistant_text": f"Ollama API Request Error: {str(e)}", "tool_calls": []} 575 | except json.JSONDecodeError as e: 576 | logger.error(f"Failed to decode JSON response from Ollama API: {e}") 577 | return {"assistant_text": f"Ollama API JSON Decode Error: {str(e)}", "tool_calls": []} 578 | except ValidationError as e: # Catch Pydantic errors during initial raw parsing if they occur 579 | # This specific check might be less likely now, but kept for safety 580 | error_str = str(e) 581 | logger.error(f"Caught Pydantic validation error during httpx handling: {e}") 582 | return { 583 | "assistant_text": f"Ollama SDK Validation Error during httpx handling: {error_str}", 584 | "tool_calls": [] 585 | } 586 | except Exception as e: 587 | logger.error(f"Unexpected error during Ollama API call via httpx: {e}") 588 | traceback.print_exc() 589 | return {"assistant_text": f"Unexpected error during httpx API call: {str(e)}", "tool_calls": []} 590 | -------------------------------------------------------------------------------- /src/dolphin_mcp/providers/openai.py: -------------------------------------------------------------------------------- 1 | """ 2 | OpenAI provider implementation for Dolphin MCP. 3 | """ 4 | 5 | import os 6 | import json 7 | from typing import Dict, List, Any, AsyncGenerator, Optional, Union 8 | 9 | from openai import AsyncOpenAI, APIError, RateLimitError 10 | 11 | async def generate_with_openai_stream(client: AsyncOpenAI, model_name: str, conversation: List[Dict], 12 | formatted_functions: List[Dict], temperature: Optional[float] = None, 13 | top_p: Optional[float] = None, max_tokens: Optional[int] = None) -> AsyncGenerator: 14 | """Internal function for streaming generation""" 15 | try: 16 | response = await client.chat.completions.create( 17 | model=model_name, 18 | messages=conversation, 19 | temperature=temperature, 20 | top_p=top_p, 21 | max_tokens=max_tokens, 22 | tools=[{"type": "function", "function": f} for f in formatted_functions], 23 | tool_choice="auto", 24 | stream=True 25 | ) 26 | 27 | current_tool_calls = [] 28 | current_content = "" 29 | 30 | async for chunk in response: 31 | delta = chunk.choices[0].delta 32 | 33 | if delta.content: 34 | # Immediately yield each token without buffering 35 | yield {"assistant_text": delta.content, "tool_calls": [], "is_chunk": True, "token": True} 36 | current_content += delta.content 37 | 38 | # Handle tool call updates 39 | if delta.tool_calls: 40 | for tool_call in delta.tool_calls: 41 | # Initialize or update tool call 42 | while tool_call.index >= len(current_tool_calls): 43 | current_tool_calls.append({ 44 | "id": "", 45 | "function": { 46 | "name": "", 47 | "arguments": "" 48 | } 49 | }) 50 | 51 | current_tool = current_tool_calls[tool_call.index] 52 | 53 | # Update tool call properties 54 | if tool_call.id: 55 | current_tool["id"] = tool_call.id 56 | 57 | if tool_call.function.name: 58 | current_tool["function"]["name"] = ( 59 | current_tool["function"]["name"] + tool_call.function.name 60 | ) 61 | 62 | if tool_call.function.arguments: 63 | # Properly accumulate JSON arguments 64 | current_args = current_tool["function"]["arguments"] 65 | new_args = tool_call.function.arguments 66 | 67 | # Handle special cases for JSON accumulation 68 | if new_args.startswith("{") and not current_args: 69 | current_tool["function"]["arguments"] = new_args 70 | elif new_args.endswith("}") and current_args: 71 | # If we're receiving the end of the JSON object 72 | if not current_args.endswith("}"): 73 | current_tool["function"]["arguments"] = current_args + new_args 74 | else: 75 | # Middle part of JSON - append carefully 76 | current_tool["function"]["arguments"] += new_args 77 | 78 | # If this is the last chunk, yield final state with complete tool calls 79 | if chunk.choices[0].finish_reason is not None: 80 | # Clean up and validate tool calls 81 | final_tool_calls = [] 82 | for tc in current_tool_calls: 83 | if tc["id"] and tc["function"]["name"]: 84 | try: 85 | # Ensure arguments is valid JSON 86 | args = tc["function"]["arguments"].strip() 87 | if not args or args.isspace(): 88 | args = "{}" 89 | # Parse and validate JSON 90 | parsed_args = json.loads(args) 91 | tc["function"]["arguments"] = json.dumps(parsed_args) 92 | final_tool_calls.append(tc) 93 | except json.JSONDecodeError: 94 | # If arguments are malformed, try to fix common issues 95 | args = tc["function"]["arguments"].strip() 96 | # Remove any trailing commas 97 | args = args.rstrip(",") 98 | # Ensure proper JSON structure 99 | if not args.startswith("{"): 100 | args = "{" + args 101 | if not args.endswith("}"): 102 | args = args + "}" 103 | try: 104 | # Try parsing again after fixes 105 | parsed_args = json.loads(args) 106 | tc["function"]["arguments"] = json.dumps(parsed_args) 107 | final_tool_calls.append(tc) 108 | except json.JSONDecodeError: 109 | # If still invalid, default to empty object 110 | tc["function"]["arguments"] = "{}" 111 | final_tool_calls.append(tc) 112 | 113 | yield { 114 | "assistant_text": current_content, 115 | "tool_calls": final_tool_calls, 116 | "is_chunk": False 117 | } 118 | 119 | except Exception as e: 120 | yield {"assistant_text": f"OpenAI error: {str(e)}", "tool_calls": [], "is_chunk": False} 121 | 122 | async def generate_with_openai_sync(client: AsyncOpenAI, model_name: str, conversation: List[Dict], 123 | formatted_functions: List[Dict], temperature: Optional[float] = None, 124 | top_p: Optional[float] = None, max_tokens: Optional[int] = None) -> Dict: 125 | """Internal function for non-streaming generation""" 126 | try: 127 | response = await client.chat.completions.create( 128 | model=model_name, 129 | messages=conversation, 130 | temperature=temperature, 131 | top_p=top_p, 132 | max_tokens=max_tokens, 133 | tools=[{"type": "function", "function": f} for f in formatted_functions], 134 | tool_choice="auto", 135 | stream=False 136 | ) 137 | 138 | choice = response.choices[0] 139 | assistant_text = choice.message.content or "" 140 | tool_calls = [] 141 | 142 | if hasattr(choice.message, 'tool_calls') and choice.message.tool_calls: 143 | for tc in choice.message.tool_calls: 144 | if tc.type == 'function': 145 | tool_call = { 146 | "id": tc.id, 147 | "function": { 148 | "name": tc.function.name, 149 | "arguments": tc.function.arguments or "{}" 150 | } 151 | } 152 | # Ensure arguments is valid JSON 153 | try: 154 | json.loads(tool_call["function"]["arguments"]) 155 | except json.JSONDecodeError: 156 | tool_call["function"]["arguments"] = "{}" 157 | tool_calls.append(tool_call) 158 | return {"assistant_text": assistant_text, "tool_calls": tool_calls} 159 | 160 | except APIError as e: 161 | return {"assistant_text": f"OpenAI API error: {str(e)}", "tool_calls": []} 162 | except RateLimitError as e: 163 | return {"assistant_text": f"OpenAI rate limit: {str(e)}", "tool_calls": []} 164 | except Exception as e: 165 | return {"assistant_text": f"Unexpected OpenAI error: {str(e)}", "tool_calls": []} 166 | 167 | async def generate_with_openai(conversation: List[Dict], model_cfg: Dict, 168 | all_functions: List[Dict], stream: bool = False) -> Union[Dict, AsyncGenerator]: 169 | """ 170 | Generate text using OpenAI's API. 171 | 172 | Args: 173 | conversation: The conversation history 174 | model_cfg: Configuration for the model 175 | all_functions: Available functions for the model to call 176 | stream: Whether to stream the response 177 | 178 | Returns: 179 | If stream=False: Dict containing assistant_text and tool_calls 180 | If stream=True: AsyncGenerator yielding chunks of assistant text and tool calls 181 | """ 182 | api_key = model_cfg.get("apiKey") or os.getenv("OPENAI_API_KEY") 183 | if "apiBase" in model_cfg: 184 | client = AsyncOpenAI(api_key=api_key, base_url=model_cfg["apiBase"]) 185 | else: 186 | client = AsyncOpenAI(api_key=api_key) 187 | 188 | model_name = model_cfg["model"] 189 | temperature = model_cfg.get("temperature", None) 190 | top_p = model_cfg.get("top_p", None) 191 | max_tokens = model_cfg.get("max_tokens", None) 192 | 193 | # Format functions for OpenAI API 194 | formatted_functions = [] 195 | for func in all_functions: 196 | formatted_func = { 197 | "name": func["name"], 198 | "description": func["description"], 199 | "parameters": func["parameters"] 200 | } 201 | formatted_functions.append(formatted_func) 202 | 203 | if stream: 204 | return generate_with_openai_stream( 205 | client, model_name, conversation, formatted_functions, 206 | temperature, top_p, max_tokens 207 | ) 208 | else: 209 | return await generate_with_openai_sync( 210 | client, model_name, conversation, formatted_functions, 211 | temperature, top_p, max_tokens 212 | ) 213 | -------------------------------------------------------------------------------- /src/dolphin_mcp/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for Dolphin MCP. 3 | """ 4 | 5 | import os 6 | import sys 7 | import json 8 | import yaml # Added for YAML support 9 | import logging 10 | import dotenv 11 | from typing import Dict, Optional 12 | 13 | # Configure logging 14 | logging.basicConfig(level=logging.CRITICAL) 15 | logger = logging.getLogger("dolphin_mcp") 16 | logger.setLevel(logging.CRITICAL) 17 | 18 | # Load environment variables 19 | dotenv.load_dotenv(override=True) 20 | 21 | async def load_config_from_file(config_path: str) -> dict: 22 | """ 23 | Load configuration from a JSON or YAML file. 24 | The file type is determined by the extension (.json or .yml/.yaml). 25 | 26 | Args: 27 | config_path: Path to the configuration file 28 | 29 | Returns: 30 | Dict containing the configuration 31 | 32 | Raises: 33 | SystemExit: If the file is not found, has an unsupported extension, or contains invalid data. 34 | """ 35 | try: 36 | with open(config_path, "r") as f: 37 | if config_path.endswith(".json"): 38 | return json.load(f) 39 | elif config_path.endswith(".yml") or config_path.endswith(".yaml"): 40 | return yaml.safe_load(f) 41 | else: 42 | print(f"Error: Unsupported configuration file extension for {config_path}. Please use .json, .yml, or .yaml.") 43 | sys.exit(1) 44 | except FileNotFoundError: 45 | print(f"Error: {config_path} not found.") 46 | sys.exit(1) 47 | except json.JSONDecodeError: 48 | print(f"Error: Invalid JSON in {config_path}.") 49 | sys.exit(1) 50 | except yaml.YAMLError as e: 51 | print(f"Error: Invalid YAML in {config_path}: {e}") 52 | sys.exit(1) 53 | 54 | def parse_arguments(): 55 | """ 56 | Parse command-line arguments. 57 | 58 | Returns: 59 | Tuple containing (chosen_model, user_query, quiet_mode, chat_mode, interactive_mode, config_path, mcp_config_path, log_messages_path) 60 | """ 61 | args = sys.argv[1:] 62 | chosen_model = None 63 | quiet_mode = False 64 | chat_mode = False 65 | interactive_mode = False # Added interactive_mode 66 | config_path = "config.yml" # default 67 | mcp_config_path = "examples/sqlite-mcp.json" # default 68 | log_messages_path = None 69 | user_query_parts = [] 70 | i = 0 71 | while i < len(args): 72 | if args[i] == "--model": 73 | if i + 1 < len(args): 74 | chosen_model = args[i+1] 75 | i += 2 76 | else: 77 | print("Error: --model requires an argument") 78 | sys.exit(1) 79 | elif args[i] == "--quiet": 80 | quiet_mode = True 81 | i += 1 82 | elif args[i] == "--chat": 83 | chat_mode = True 84 | i += 1 85 | elif args[i] == "--interactive" or args[i] == "-i": # Added interactive mode check 86 | interactive_mode = True 87 | i += 1 88 | elif args[i] == "--config": 89 | if i + 1 < len(args): 90 | config_path = args[i+1] 91 | i += 2 92 | else: 93 | print("Error: --config requires an argument") 94 | sys.exit(1) 95 | elif args[i] == "--log-messages": 96 | if i + 1 < len(args): 97 | log_messages_path = args[i+1] 98 | i += 2 99 | else: 100 | print("Error: --log-messages requires an argument") 101 | sys.exit(1) 102 | elif args[i] == "--mcp-config": 103 | if i + 1 < len(args): 104 | mcp_config_path = args[i+1] 105 | i += 2 106 | else: 107 | print("Error: --mcp-config requires an argument") 108 | sys.exit(1) 109 | elif args[i] == "--help" or args[i] == "-h": 110 | # Skip help flags as they're handled in the main function 111 | i += 1 112 | else: 113 | user_query_parts.append(args[i]) 114 | i += 1 115 | 116 | user_query = " ".join(user_query_parts) 117 | return chosen_model, user_query, quiet_mode, chat_mode, interactive_mode, config_path, mcp_config_path, log_messages_path 118 | --------------------------------------------------------------------------------