diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..38c1d18 Binary files /dev/null and b/.DS_Store differ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..971731d --- /dev/null +++ b/README.md @@ -0,0 +1,333 @@ +# Doris MCP Server + +Doris MCP (Model Control Panel) Server is a backend service built with Python and FastAPI. It implements the MCP (Model Control Panel) protocol, allowing clients to interact with it through defined "Tools". It's primarily designed to connect to Apache Doris databases, potentially leveraging Large Language Models (LLMs) for tasks like converting natural language queries to SQL (NL2SQL), executing queries, and performing metadata management and analysis. + +## Core Features + +* **MCP Protocol Implementation**: Provides standard MCP interfaces, supporting tool calls, resource management, and prompt interactions. +* **Multiple Communication Modes**: + * **SSE (Server-Sent Events)**: Served via `/sse` (initialization) and `/mcp/messages` (communication) endpoints (`src/sse_server.py`). + * **Streamable HTTP**: Served via the unified `/mcp` endpoint, supporting request/response and streaming (`src/streamable_server.py`). + * **(Optional) Stdio**: Interaction possible via standard input/output (`src/stdio_server.py`), requires specific startup configuration. +* **Tool-Based Interface**: Core functionalities are encapsulated as MCP tools that clients can call as needed. Currently available key tools focus on direct database interaction: + * SQL Execution (`mcp_doris_exec_query`) + * Database and Table Listing (`mcp_doris_get_db_list`, `mcp_doris_get_db_table_list`) + * Metadata Retrieval (`mcp_doris_get_table_schema`, `mcp_doris_get_table_comment`, `mcp_doris_get_table_column_comments`, `mcp_doris_get_table_indexes`) + * Audit Log Retrieval (`mcp_doris_get_recent_audit_logs`) + *Note: Current tools primarily focus on direct DB operations.* +* **Database Interaction**: Provides functionality to connect to Apache Doris (or other compatible databases) and execute queries (`src/utils/db.py`). +* **Flexible Configuration**: Configured via a `.env` file, supporting settings for database connections, LLM providers/models, API keys, logging levels, etc. +* **Metadata Extraction**: Capable of extracting database metadata information (`src/utils/schema_extractor.py`). + +## System Requirements + +* Python 3.12+ +* Database connection details (e.g., Doris Host, Port, User, Password, Database) + +## Quick Start + +### 1. Clone the Repository + +```bash +# Replace with the actual repository URL if different +git clone https://github.com/apache/doris-mcp-server.git +cd doris-mcp-server +``` + +### 2. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 3. Configure Environment Variables + +Copy the `.env.example` file to `.env` and modify the settings according to your environment: + +```bash +cp env.example .env +``` + +**Key Environment Variables:** + +* **Database Connection**: + * `DB_HOST`: Database hostname + * `DB_PORT`: Database port (default 9030) + * `DB_USER`: Database username + * `DB_PASSWORD`: Database password + * `DB_DATABASE`: Default database name +* **Server Configuration**: + * `SERVER_HOST`: Host address the server listens on (default `0.0.0.0`) + * `SERVER_PORT`: Port the server listens on (default `3000`) + * `ALLOWED_ORIGINS`: CORS allowed origins (comma-separated, `*` allows all) + * `MCP_ALLOW_CREDENTIALS`: Whether to allow CORS credentials (default `false`) +* **Logging Configuration**: + * `LOG_DIR`: Directory for log files (default `./logs`) + * `LOG_LEVEL`: Log level (e.g., `INFO`, `DEBUG`, `WARNING`, `ERROR`, default `INFO`) + * `CONSOLE_LOGGING`: Whether to output logs to the console (default `false`) + +### Available MCP Tools + +The following table lists the main tools currently available for invocation via an MCP client: + +| Tool Name | Description | Parameters | Status | +| :-------------------------------- | :---------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------- | :------- | +| `mcp_doris_get_db_list` | Get a list of all database names on the server. | `random_string` (string, Required) | ✅ Active | +| `mcp_doris_get_db_table_list` | Get a list of all table names in the specified database. | `random_string` (string, Required), `db_name` (string, Optional, defaults to current db) | ✅ Active | +| `mcp_doris_get_table_schema` | Get detailed structure of the specified table. | `random_string` (string, Required), `table_name` (string, Required), `db_name` (string, Optional) | ✅ Active | +| `mcp_doris_get_table_comment` | Get the comment for the specified table. | `random_string` (string, Required), `table_name` (string, Required), `db_name` (string, Optional) | ✅ Active | +| `mcp_doris_get_table_column_comments` | Get comments for all columns in the specified table. | `random_string` (string, Required), `table_name` (string, Required), `db_name` (string, Optional) | ✅ Active | +| `mcp_doris_get_table_indexes` | Get index information for the specified table. | `random_string` (string, Required), `table_name` (string, Required), `db_name` (string, Optional) | ✅ Active | +| `mcp_doris_exec_query` | Execute SQL query and return result command. | `random_string` (string, Required), `sql` (string, Required), `db_name` (string, Optional), `max_rows` (integer, Optional, default 100), `timeout` (integer, Optional, default 30) | ✅ Active | +| `mcp_doris_get_recent_audit_logs` | Get audit log records for a recent period. | `random_string` (string, Required), `days` (integer, Optional, default 7), `limit` (integer, Optional, default 100) | ✅ Active | + +**Note:** All tools require a `random_string` parameter as a call identifier, typically handled automatically by the MCP client. "Optional" and "Required" refer to the tool's internal logic; the client might need to provide values for all parameters depending on its implementation. The tool names listed here are the base names; clients might see them prefixed (e.g., `mcp_doris_stdio3_get_db_list`) depending on the connection mode. + +### 4. Run the Service + +If you use SSE mode, execute the following command: + +```bash +./start_server.sh +``` + +This command starts the FastAPI application, providing both SSE and Streamable HTTP MCP services by default. + +**Service Endpoints:** + +* **SSE Initialization**: `http://:/sse` +* **SSE Communication**: `http://:/mcp/messages` (POST) +* **Streamable HTTP**: `http://:/mcp` (Supports GET, POST, DELETE, OPTIONS) +* **Health Check**: `http://:/health` +* **(Potential) Status Check**: `http://:/status` (Confirm if implemented in `main.py`) + +## Usage + +Interaction with the Doris MCP Server requires an **MCP Client**. The client connects to the server's SSE or Streamable HTTP endpoints and sends requests (like `tool_call`) according to the MCP specification to invoke the server's tools. + +**Main Interaction Flow:** + +1. **Client Initialization**: Connect to `/sse` (SSE) or send an `initialize` method call to `/mcp` (Streamable). +2. **(Optional) Discover Tools**: The client can call `mcp/listTools` or `mcp/listOfferings` to get the list of supported tools, their descriptions, and parameter schemas. +3. **Call Tool**: The client sends a `tool_call` message/request, specifying the `tool_name` and `arguments`. + * **Example: Get Table Schema** + * `tool_name`: `mcp_doris_get_table_schema` (or the mode-specific name) + * `arguments`: Include `random_string`, `table_name`, `db_name`. +4. **Handle Response**: + * **Non-streaming**: The client receives a response containing `result` or `error`. + * **Streaming**: The client receives a series of `tools/progress` notifications, followed by a final response containing the `result` or `error`. + +Specific tool names and parameters should be referenced from the `src/tools/` code or obtained via MCP discovery mechanisms. + +## Connecting with Cursor + +You can connect Cursor to this MCP server using either Stdio or SSE mode. + +### Stdio Mode + +Stdio mode allows Cursor to manage the server process directly. Configuration is done within Cursor's MCP Server settings file (typically `~/.cursor/mcp.json` or similar). + +If you use stdio mode, please execute the following command to download and build the environment dependency package, **but please note that you need to change the project path to the correct path address**: + +```bash +uv --project /your/path/doris-mcp-server run doris-mcp +``` + +1. **Configure Cursor:** Add an entry like the following to your Cursor MCP configuration: + + ```json + { + "mcpServers": { + "doris-stdio": { + "command": "uv", + "args": ["--project", "/path/to/your/doris-mcp-server", "run", "doris-mcp"], + "env": { + "DB_HOST": "127.0.0.1", + "DB_PORT": "9030", + "DB_USER": "root", + "DB_PASSWORD": "your_db_password", + "DB_DATABASE": "your_default_db" + } + }, + // ... other server configurations ... + } + } + ``` + +2. **Key Points:** + * Replace `/path/to/your/doris-mcp` with the actual absolute path to the project's root directory on your system. The `--project` argument is crucial for `uv` to find the `pyproject.toml` and run the correct command. + * The `command` is set to `uv` (assuming you use `uv` for package management as indicated by `uv.lock`). The `args` include `--project`, the path, `run`, and `mcp-doris` (which should correspond to a script defined in your `pyproject.toml`). + * Database connection details (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_DATABASE`) are set directly in the `env` block within the configuration file. Cursor will pass these to the server process. No `.env` file is needed for this mode when configured via Cursor. + +### SSE Mode + +SSE mode requires you to run the MCP server independently first, and then tell Cursor how to connect to it. + +1. **Configure `.env`:** Ensure your database credentials and any other necessary settings (like `SERVER_PORT` if not using the default 3000) are correctly configured in the `.env` file within the project directory. +2. **Start the Server:** Run the server from your terminal in the project's root directory: + ```bash + ./start_server.sh + ``` + This script typically reads the `.env` file and starts the FastAPI server in SSE mode (check the script and `sse_server.py` / `main.py` for specifics). Note the host and port the server is listening on (default is `0.0.0.0:3000`). +3. **Configure Cursor:** Add an entry like the following to your Cursor MCP configuration, pointing to the running server's SSE endpoint: + + ```json + { + "mcpServers": { + "doris-sse": { + "url": "http://127.0.0.1:3000/sse" // Adjust host/port if your server runs elsewhere + }, + // ... other server configurations ... + } + } + ``` + *Note: The example uses the default port `3000`. If your server is configured to run on a different port (like `3010` in the user example), adjust the URL accordingly.* + +After configuring either mode in Cursor, you should be able to select the server (e.g., `doris-stdio` or `doris-sse`) and use its tools. + +## Directory Structure + +``` +doris-mcp-server/ +├── doris_mcp_server/ # Source code for the MCP server +│ ├── main.py # Main entry point, FastAPI app definition +│ ├── mcp_core.py # Core MCP tool registration and Stdio handling +│ ├── sse_server.py # SSE server implementation +│ ├── streamable_server.py # Streamable HTTP server implementation +│ ├── config.py # Configuration loading +│ ├── tools/ # MCP tool definitions +│ │ ├── mcp_doris_tools.py # Main Doris-related MCP tools +│ │ ├── tool_initializer.py # Tool registration helper (used by mcp_core.py) +│ │ └── __init__.py +│ ├── utils/ # Utility classes and helper functions +│ │ ├── db.py # Database connection and operations +│ │ ├── logger.py # Logging configuration +│ │ ├── schema_extractor.py # Doris metadata/schema extraction logic +│ │ ├── sql_executor_tools.py # SQL execution helper (might be legacy) +│ │ └── __init__.py +│ └── __init__.py +├── logs/ # Log file directory (if file logging enabled) +├── README.md # This file +├── .env.example # Example environment variable file +├── requirements.txt # Python dependencies for pip +├── pyproject.toml # Project metadata and build system configuration (PEP 518) +├── uv.lock # Lock file for 'uv' package manager (alternative to pip) +├── start_server.sh # Script to start the server +└── restart_server.sh # Script to restart the server +``` + +## Developing New Tools + +This section outlines the process for adding new MCP tools to the Doris MCP Server, considering the current project structure. + +### 1. Leverage Utility Modules + +Before writing new database interaction logic from scratch, check the existing utility modules: + +* **`doris_mcp_server/utils/db.py`**: Provides basic functions for getting database connections (`get_db_connection`) and executing raw queries (`execute_query`, `execute_query_df`). +* **`doris_mcp_server/utils/schema_extractor.py` (`MetadataExtractor` class)**: Offers high-level methods to retrieve database metadata, such as listing databases/tables (`get_all_databases`, `get_database_tables`), getting table schemas/comments/indexes (`get_table_schema`, `get_table_comment`, `get_column_comments`, `get_table_indexes`), and accessing audit logs (`get_recent_audit_logs`). It includes caching mechanisms. +* **`doris_mcp_server/utils/sql_executor_tools.py` (`execute_sql_query` function)**: Provides a wrapper around `db.execute_query` that includes security checks (optional, controlled by `ENABLE_SQL_SECURITY_CHECK` env var), adds automatic `LIMIT` to SELECT queries, handles result serialization (dates, decimals), and formats the output into the standard MCP success/error structure. **It's recommended to use this for executing user-provided or generated SQL.** + +You can import and combine functionalities from these modules to build your new tool. + +### 2. Implement Tool Logic + +Implement the core logic for your new tool as an `async` function within `doris_mcp_server/tools/mcp_doris_tools.py`. This keeps the primary tool implementations centralized. Ensure your function returns data in a format that can be easily wrapped into the standard MCP response structure (see `_format_response` in the same file for reference). + +**Example:** Let's create a simple tool `get_server_time`. + +```python +# In doris_mcp_server/tools/mcp_doris_tools.py +import datetime +# ... other imports ... +from doris_mcp_server.tools.mcp_doris_tools import _format_response # Reuse formatter + +# ... existing tools ... + +async def mcp_doris_get_server_time() -> Dict[str, Any]: + """Gets the current server time.""" + logger.info(f"MCP Tool Call: mcp_doris_get_server_time") + try: + current_time = datetime.datetime.now().isoformat() + # Use the existing formatter for consistency + return _format_response(success=True, result={"server_time": current_time}) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_server_time: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting server time") + +``` + +### 3. Register the Tool (Dual Registration) + +Due to the separate handling of SSE/Streamable and Stdio modes, you need to register the tool in two places: + +**A. SSE/Streamable Registration (`tool_initializer.py`)** + +* Import your new tool function from `mcp_doris_tools.py`. +* Inside the `register_mcp_tools` function, add a new wrapper function decorated with `@mcp.tool()`. +* The wrapper function should call your core tool function. +* Define the tool name and provide a detailed description (including parameters if any) in the decorator. Remember to include the mandatory `random_string` parameter description for client compatibility, even if your wrapper doesn't explicitly use it. + +**Example (`tool_initializer.py`):** + +```python +# In doris_mcp_server/tools/tool_initializer.py +# ... other imports ... +from doris_mcp_server.tools.mcp_doris_tools import ( + # ... existing tool imports ... + mcp_doris_get_server_time # <-- Import the new tool +) + +async def register_mcp_tools(mcp): + # ... existing tool registrations ... + + # Register Tool: Get Server Time + @mcp.tool("get_server_time", description="""[Function Description]: Get the current time of the MCP server.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n""") + async def get_server_time_tool() -> Dict[str, Any]: + """Wrapper: Get server time""" + # Note: No parameters needed for the core function call here + return await mcp_doris_get_server_time() + + # ... logging registration count ... +``` + +**B. Stdio Registration (`mcp_core.py`)** + +* Similar to SSE, add a new wrapper function decorated with `@stdio_mcp.tool()`. +* **Important:** Import your core tool function (`mcp_doris_get_server_time`) *inside* the wrapper function (delayed import pattern used in this file). +* The wrapper calls the core tool function. The wrapper itself *might* need to be `async def` depending on how `FastMCP` handles tools in Stdio mode, even if the underlying function is simple (as seen in the current file structure). Ensure the call matches (e.g., use `await` if calling an async function). + +**Example (`mcp_core.py`):** + +```python +# In doris_mcp_server/mcp_core.py +# ... other imports and setup ... + +# ... existing Stdio tool registrations ... + +# Register Tool: Get Server Time (for Stdio) +@stdio_mcp.tool("get_server_time", description="""[Function Description]: Get the current time of the MCP server.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n""") +async def get_server_time_tool_stdio() -> Dict[str, Any]: # Using a slightly different wrapper name for clarity if needed + """Wrapper: Get server time (Stdio)""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_server_time # <-- Delayed import + # Assuming the Stdio runner handles async wrappers correctly + return await mcp_doris_get_server_time() + +# --- Register Tools --- (Or wherever the registrations are finalized) +``` + +### 4. Restart and Test + +After implementing and registering the tool in both files, restart the MCP server (both SSE mode via `./start_server.sh` and ensure the Stdio command used by Cursor is updated if necessary) and test the new tool using your MCP client (like Cursor) in both connection modes. + +## Contributing + +Contributions are welcome via Issues or Pull Requests. + +## License + +This project is licensed under the Apache 2.0 License. See the LICENSE file (if it exists) for details. diff --git a/doris_mcp_server/__init__.py b/doris_mcp_server/__init__.py new file mode 100644 index 0000000..390ee6e --- /dev/null +++ b/doris_mcp_server/__init__.py @@ -0,0 +1 @@ +# Mark directory as a package \ No newline at end of file diff --git a/doris_mcp_server/config.py b/doris_mcp_server/config.py new file mode 100644 index 0000000..c80fdae --- /dev/null +++ b/doris_mcp_server/config.py @@ -0,0 +1,33 @@ +# doris_mcp_server/config.py +import os +import logging +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv(override=True) + +# Get Log Level from environment variable, default to 'info' +LOG_LEVEL_STR = os.getenv('LOG_LEVEL', 'info').upper() + +# Map string level to logging level constant +LOG_LEVEL_MAP = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL +} +LOG_LEVEL = LOG_LEVEL_MAP.get(LOG_LEVEL_STR, logging.INFO) + +# Function to load config (can be expanded later if needed) +def load_config(): + """Loads configuration settings.""" + # Currently, configuration is mainly handled by environment variables + # and constants defined in this module. + # This function can be used to perform additional setup if required. + logging.getLogger(__name__).info("Configuration loaded (mainly from environment variables).") + +# You can add other configuration constants here if needed +# Example: DB_HOST = os.getenv("DB_HOST", "localhost") +# But often it's better to access os.getenv directly where needed +# or pass config dictionaries around. \ No newline at end of file diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py new file mode 100644 index 0000000..d447aca --- /dev/null +++ b/doris_mcp_server/main.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Apache Doris MCP Server Main Entry - Primarily handles SSE mode + +Stdio mode is handled by doris_mcp_server.mcp_core:run_stdio. +""" + +import os +import sys +import argparse +import asyncio +import logging +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Dict, Any +import uvicorn +from uvicorn import Config, Server +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from dotenv import load_dotenv + +# Add project root to path +PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +# SSE related imports +from mcp.server.fastmcp import FastMCP +from doris_mcp_server.sse_server import DorisMCPSseServer +from doris_mcp_server.streamable_server import DorisMCPStreamableServer + +# Stdio related imports (only needed for tools now, maybe move tool init?) +# from mcp.server.stdio import stdio_server -> No longer used here + +# Config and Tool Initializer +from doris_mcp_server.config import load_config # LOG_LEVEL might not be needed here directly +from doris_mcp_server.tools.tool_initializer import register_mcp_tools + +# Load environment variables (load early for all modes) +load_dotenv(override=True) + +# Get logger +logger = logging.getLogger("doris-mcp-main") # Changed logger name slightly + +# --- Configuration Loading and Logging Setup --- +load_config() # Loads .env + +# --- Create FastAPI App (Global Scope for SSE Mode) --- +# This 'app' object is targeted by 'mcp run doris_mcp_server/main.py:app --transport sse' +# And used when running directly with --sse +app = FastAPI( + title="Doris MCP Server (SSE Mode)", + # Lifespan will be added in start_sse_server +) + +# --- Removed StdioServerWrapper --- + +# --- Command Line Argument Parsing --- +def parse_args(): + parser = argparse.ArgumentParser(description="Apache Doris MCP Server (SSE Mode Entry)") + # Only keep SSE related args here + parser.add_argument('--sse', action='store_true', help='Start SSE Web server mode (required)') + parser.add_argument('--host', type=str, default=os.getenv('SERVER_HOST', '0.0.0.0'), help='Host address') + parser.add_argument('--port', type=int, default=int(os.getenv('SERVER_PORT', os.getenv('MCP_PORT', '3000'))), help='Port number') + parser.add_argument('--debug', action='store_true', help='Enable debug mode') + parser.add_argument('--reload', action='store_true', help='Enable auto-reload') + return parser.parse_args() + +# --- SSE Mode Specific Code --- +@dataclass +class AppContext: + config: Dict[str, Any] + +@asynccontextmanager +async def app_lifespan(app_instance: FastAPI) -> AsyncIterator[None]: + logger.info("SSE application lifecycle start...") + config = { + # Simplified config - maybe get from elsewhere? + "db_host": os.getenv("DB_HOST", "localhost"), + "db_port": int(os.getenv("DB_PORT", "9030")), + "db_user": os.getenv("DB_USER", "root"), + "db_password": os.getenv("DB_PASSWORD", ""), + "db_database": os.getenv("DB_DATABASE", "test"), + } + app_instance.state.config = config + try: + # Yield None implicitly or explicitly None + yield + finally: + logger.info("Cleaning up SSE application resources...") + +async def start_sse_server(args): + """Start SSE Web server mode (Configures the global 'app')""" + logger.info("Starting SSE Web server mode...") + global app + + # --- Initialize MCP and Tools for SSE --- + # Create a *separate* MCP instance for SSE mode + sse_mcp = FastMCP( + name="doris-mcp-sse", + description="Apache Doris MCP Server (SSE)", + lifespan=None, # Managed by FastAPI + dependencies=["fastapi", "uvicorn", "openai", "sse_starlette"] + ) + logger.info("Registering MCP tools for SSE mode...") + await register_mcp_tools(sse_mcp) # Register tools for the SSE instance + logger.info("MCP tools registered for SSE.") + + # --- Configure Lifespan and CORS for the global app --- + app.router.lifespan_context = app_lifespan + origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") + allow_credentials = os.getenv("MCP_ALLOW_CREDENTIALS", "false").lower() == "true" + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=allow_credentials, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Mcp-Session-Id"], + ) + + # --- Initialize Handlers and Register Routes (Pass sse_mcp instance) --- + logger.info("Initializing SSE server handlers and registering routes...") + sse_server_handler = DorisMCPSseServer(sse_mcp, app) + streamable_server_handler = DorisMCPStreamableServer(sse_mcp, app) + logger.info("SSE Server handlers initialized and routes registered.") + + # --- Print Configuration and Endpoints --- + print("--- SSE Mode Configuration ---") + print(f"Server Host: {args.host}") + print(f"Server Port: {args.port}") + print(f"Allowed Origins: {origins}") + print(f"Allow Credentials: {allow_credentials}") + print(f"Log Level: {os.getenv('LOG_LEVEL', 'info')}") + print(f"Debug Mode: {args.debug}") + print(f"Reload Mode: {args.reload}") + print(f"DB Host: {os.getenv('DB_HOST')}") + print(f"DB Port: {os.getenv('DB_PORT')}") + print(f"DB User: {os.getenv('DB_USER')}") + print(f"DB Database: {os.getenv('DB_DATABASE')}") + print(f"Force Refresh Metadata: {os.getenv('FORCE_REFRESH_METADATA', 'false')}") + print("------------------------------") + base_url = f"http://{args.host}:{args.port}" + print(f"Service running at: {base_url}") + print(f" Health Check: GET {base_url}/health") + print(f" Status Check: GET {base_url}/status") + print(f" SSE Init: GET {base_url}/sse") + print(f" SSE/Legacy Messages: POST {base_url}/mcp/messages") + print(f" Streamable HTTP: GET/POST/DELETE/OPTIONS {base_url}/mcp") + print("------------------------------") + print("Use Ctrl+C to stop the service") + + # --- Start Uvicorn Server --- + config = Config( + app=app, + host=args.host, + port=args.port, + log_level="debug" if args.debug else "info", + reload=args.reload + ) + server = Server(config=config) + await server.serve() + +# --- Main Execution Logic (Simplified) --- + +def run_main_sync(): + """Synchronous wrapper, primarily for SSE mode now.""" + sync_logger = logging.getLogger("run_main_sync") + sync_logger.info("Entering run_main_sync (SSE focus)...") + print("DEBUG: Entering run_main_sync (SSE focus)...", file=sys.stderr, flush=True) + args = parse_args() + + if args.sse: + try: + # Run the async SSE server setup and Uvicorn loop + asyncio.run(start_sse_server(args)) + sync_logger.info("asyncio.run(start_sse_server) completed.") + print("DEBUG: asyncio.run(start_sse_server) completed.", file=sys.stderr, flush=True) + except KeyboardInterrupt: + sync_logger.info("SSE server stopped by KeyboardInterrupt.") + except Exception as e: + sync_logger.critical(f"Error during asyncio.run(start_sse_server): {e}", exc_info=True) + print(f"DEBUG: Error during asyncio.run(start_sse_server): {e}", file=sys.stderr, flush=True) + raise + else: + # If run without --sse, print help/error + message = "Error: This entry point requires --sse flag. For stdio mode, use 'uv run mcp-doris' or the appropriate command for your stdio setup." + sync_logger.error(message) + print(message, file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + run_main_sync() \ No newline at end of file diff --git a/doris_mcp_server/mcp_core.py b/doris_mcp_server/mcp_core.py new file mode 100644 index 0000000..72116c4 --- /dev/null +++ b/doris_mcp_server/mcp_core.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Core MCP instance and startup logic for stdio mode. +""" + +import asyncio +import logging +import sys +import traceback +import json +from typing import Dict, Any + +# Import necessary components from mcp and our project +from mcp.server.fastmcp import FastMCP + +logger = logging.getLogger("doris-mcp-core") + +# --- Global MCP Instance for Stdio --- +# Create the instance when the module is imported. +# Tools will be registered synchronously(?) before running. +stdio_mcp = FastMCP( + name="doris-mcp-stdio-core", + description="Apache Doris MCP Server (stdio via core)", +) + +# --- Removed async setup functions --- +def run_stdio(): + """ + Synchronous entry point for running the stdio server. + Mimics the mcp-doris example by calling .run() on the instance. + Handles tool registration beforehand. + """ + logger.info("Executing run_stdio (synchronous entry point)...") + + # --- Run the stdio server using the instance's run() method --- + logger.info("Calling stdio_mcp.run()...") + try: + # Assuming stdio_mcp has a synchronous run() method for stdio + stdio_mcp.run() + logger.info("stdio_mcp.run() completed.") + except KeyboardInterrupt: + logger.info("Stdio server stopped by KeyboardInterrupt.") + except AttributeError: + logger.critical("Error: stdio_mcp object does not have a '.run()' method suitable for stdio.", exc_info=False) + print("ERROR: stdio_mcp object does not have a '.run()' method.", file=sys.stderr, flush=True) + sys.exit(1) + except Exception as e: + logger.critical(f"run_stdio encountered an error during stdio_mcp.run(): {e}", exc_info=True) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + +# Register Tool: Execute SQL Query +@stdio_mcp.tool("exec_query", description="""[Function Description]: Execute SQL query and return result command (executed by the client).\n +[Parameter Content]:\n +- sql (string) [Required] - SQL statement to execute\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n +- max_rows (integer) [Optional] - Maximum number of rows to return, default 100\n +- timeout (integer) [Optional] - Query timeout in seconds, default 30\n""") +async def exec_query_tool(sql: str, db_name: str = None, max_rows: int = 100, timeout: int = 30) -> Dict[str, Any]: + """Wrapper: Execute SQL query and return result command""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_exec_query + return await mcp_doris_exec_query(sql=sql, db_name=db_name, max_rows=max_rows, timeout=timeout) + +# Register Tool: Get Table Schema +@stdio_mcp.tool("get_table_schema", description="""[Function Description]: Get detailed structure information of the specified table (columns, types, comments, etc.).\n +[Parameter Content]:\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") +async def get_table_schema_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table schema""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_table_schema + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_schema(table_name=table_name, db_name=db_name) + +# Register Tool: Get Database Table List +@stdio_mcp.tool("get_db_table_list", description="""[Function Description]: Get a list of all table names in the specified database.\n +[Parameter Content]:\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") +async def get_db_table_list_tool(db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get database table list""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_db_table_list + return await mcp_doris_get_db_table_list(db_name=db_name) + +# Register Tool: Get Database List +@stdio_mcp.tool("get_db_list", description="""[Function Description]: Get a list of all database names on the server.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n""") +async def get_db_list_tool() -> Dict[str, Any]: + """Wrapper: Get database list""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_db_list + return await mcp_doris_get_db_list() + +# Register Tool: Get Table Comment +@stdio_mcp.tool("get_table_comment", description="""[Function Description]: Get the comment information for the specified table.\n +[Parameter Content]:\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") +async def get_table_comment_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table comment""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_table_comment + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_comment(table_name=table_name, db_name=db_name) + +# Register Tool: Get Table Column Comments +@stdio_mcp.tool("get_table_column_comments", description="""[Function Description]: Get comment information for all columns in the specified table.\n +[Parameter Content]:\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") +async def get_table_column_comments_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table column comments""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_table_column_comments + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_column_comments(table_name=table_name, db_name=db_name) + +# Register Tool: Get Table Indexes +@stdio_mcp.tool("get_table_indexes", description="""[Function Description]: Get index information for the specified table. +[Parameter Content]:\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") +async def get_table_indexes_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table indexes""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_table_indexes + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_indexes(table_name=table_name, db_name=db_name) + +# Register Tool: Get Recent Audit Logs +@stdio_mcp.tool("get_recent_audit_logs", description="""[Function Description]: Get audit log records for a recent period.\n +[Parameter Content]:\n +- days (integer) [Optional] - Number of recent days of logs to retrieve, default is 7\n +- limit (integer) [Optional] - Maximum number of records to return, default is 100\n""") +async def get_recent_audit_logs_tool(days: int = 7, limit: int = 100) -> Dict[str, Any]: + """Wrapper: Get recent audit logs""" + from doris_mcp_server.tools.mcp_doris_tools import mcp_doris_get_recent_audit_logs + try: + days = int(days) + limit = int(limit) + except (ValueError, TypeError): + return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "days and limit parameters must be integers"})}]} + return await mcp_doris_get_recent_audit_logs(days=days, limit=limit) + +# --- Register Tools --- diff --git a/doris_mcp_server/sse_server.py b/doris_mcp_server/sse_server.py new file mode 100644 index 0000000..8a646e6 --- /dev/null +++ b/doris_mcp_server/sse_server.py @@ -0,0 +1,1259 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Doris MCP SSE Server Implementation + +Implements a standard MCP SSE server based on MCP's SseServerTransport, +supports bidirectional communication with clients, and integrates with the existing Doris-MCP-Server. +""" + +import asyncio +import json +import uuid +import logging +import time +from typing import Any, Optional +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from sse_starlette.sse import EventSourceResponse + +# Get logger +logger = logging.getLogger("doris-mcp-sse") + +class DorisMCPSseServer: + """Doris MCP SSE Server Implementation""" + + def __init__(self, mcp_server, app: FastAPI): + """ + Initialize the Doris MCP SSE server + + Args: + mcp_server: FastMCP server instance + app: FastAPI application instance + """ + self.mcp_server = mcp_server + + # Ensure app is a FastAPI instance + if not isinstance(app, FastAPI): + logger.warning("Passed application is not a FastAPI instance, will use the existing FastAPI instance") + + self.app = app + + # Add CORS middleware + self.app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3100"], # Specify frontend domain + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"] + ) + + # Client session management + self.client_sessions = {} + + # Set up SSE routes + self.setup_sse_routes() + + # Register startup event + @self.app.on_event("startup") + async def startup_event(): + # Start session cleanup task + asyncio.create_task(self.cleanup_idle_sessions()) + # Start task to send periodic status updates + asyncio.create_task(self.send_periodic_updates()) + + def setup_sse_routes(self): + """Set up SSE related routes""" + + @self.app.get("/health") + async def health_check(): + """Health check endpoint""" + try: + # Use direct health check logic + return { + "status": "healthy", + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "server": "Doris MCP Server" + } + except Exception as e: + return { + "status": "error", + "error": str(e), + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") + } + + @self.app.get("/status") + async def status(): + """Get server status""" + try: + # Get tool list + tools = await self.mcp_server.list_tools() + tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools] + logger.info(f"Getting tool list, currently registered tools: {tool_names}") + + # Get resource list + resources = await self.mcp_server.list_resources() + resource_names = [res.name if hasattr(res, 'name') else str(res) for res in resources] + + # Get prompt template list + prompts = await self.mcp_server.list_prompts() + prompt_names = [prompt.name if hasattr(prompt, 'name') else str(prompt) for prompt in prompts] + + return { + "status": "running", + "name": self.mcp_server.name, + "mode": "mcp_sse", + "clients": len(self.client_sessions), + "tools": tool_names, + "resources": resource_names, + "prompts": prompt_names + } + except Exception as e: + logger.error(f"Error getting status: {str(e)}") + return { + "status": "error", + "error": str(e) + } + + @self.app.get("/sse") + async def mcp_sse_init(request: Request): + """SSE service entry point, establishes client connection (New Endpoint)""" + # Generate session ID + session_id = str(uuid.uuid4()) + logger.info(f"New SSE connection [Session ID: {session_id}] at /sse") + + # Create client session + self.client_sessions[session_id] = { + "client_id": request.headers.get("X-Client-ID", f"client_{str(uuid.uuid4())[:8]}"), + "created_at": time.time(), + "last_active": time.time(), + "queue": asyncio.Queue() + } + + # Immediately put endpoint information into the queue + endpoint_data = f"/mcp/messages?session_id={session_id}" + await self.client_sessions[session_id]["queue"].put({ + "event": "endpoint", + "data": endpoint_data + }) + + # Create event generator + async def event_generator(): + try: + while True: + # Use timeout to get new messages, to detect client disconnect + try: + message = await asyncio.wait_for( + self.client_sessions[session_id]["queue"].get(), + timeout=30 + ) + + # Check if it's a close command + if isinstance(message, dict) and message.get("event") == "close": + logger.info(f"Received close command [Session ID: {session_id}]") + break + + # Return message + if isinstance(message, dict): + if "event" in message: + # If event field exists, it's a system event + event_type = message["event"] + event_data = message["data"] + yield { + "event": event_type, + "data": event_data + } + else: + # Otherwise it's a normal message, use message event + yield { + "event": "message", + "data": json.dumps(message) + } + elif isinstance(message, str): + # If it's a string, send directly + yield { + "event": "message", + "data": message + } + else: + # Other types, convert to JSON + yield { + "event": "message", + "data": json.dumps(message) + } + except asyncio.TimeoutError: + # Send ping to keep connection alive + yield { + "event": "ping", + "data": "keepalive" + } + continue + except asyncio.CancelledError: + # Connection cancelled + logger.info(f"SSE connection cancelled [Session ID: {session_id}]") + except Exception as e: + # Other error occurred + logger.error(f"SSE event generator error [Session ID: {session_id}]: {str(e)}") + finally: + # Clean up session + if session_id in self.client_sessions: + logger.info(f"Cleaning up session [Session ID: {session_id}]") + del self.client_sessions[session_id] + + # Return standard SSE response + return EventSourceResponse( + event_generator(), + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + } + ) + + @self.app.options("/mcp/messages") + async def mcp_messages_options(request: Request): + """Handle preflight requests""" + return JSONResponse( + {}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + @self.app.post("/mcp/messages") + async def mcp_messages_handler(request: Request): + """Handle client message requests, using class method""" + return await self.mcp_message(request) + + async def cleanup_idle_sessions(self): + """Clean up idle client sessions""" + while True: + await asyncio.sleep(60) # Check every minute + current_time = time.time() + + # Find sessions idle for over 5 minutes + idle_sessions = [] + for session_id, session in self.client_sessions.items(): + if current_time - session["last_active"] > 300: # 5 minutes + idle_sessions.append(session_id) + + # Close and remove idle sessions + for session_id in idle_sessions: + try: + # Send close message + await self.client_sessions[session_id]["queue"].put({"event": "close"}) + # Clean up session + logger.info(f"Cleaned up idle session: {session_id}") + except Exception as e: + logger.error(f"Error cleaning up session: {str(e)}") + finally: + # Ensure session is removed + if session_id in self.client_sessions: + del self.client_sessions[session_id] + + async def send_periodic_updates(self): + """Periodically send status updates to all clients""" + while True: + try: + # Send status update every 5 seconds + await asyncio.sleep(5) + + # If no clients are connected, skip this update + if not self.client_sessions: + continue + + # Get current status + status_data = { + "timestamp": time.time(), + "clients_count": len(self.client_sessions), + "server_status": "running" + } + + # Send status update to all clients + await self.broadcast_status_update(status_data) + except Exception as e: + logger.error(f"Error sending periodic updates: {str(e)}") + # Wait a bit after error before continuing + await asyncio.sleep(1) + + async def broadcast_status_update(self, status_data): + """Broadcast status update to all clients + + Args: + status_data: Status data + """ + logger.debug(f"Broadcasting status update: {status_data}") + message = { + "jsonrpc": "2.0", + "method": "notifications/status", + "params": { + "type": "status_update", + "data": status_data + } + } + await self.broadcast_message(message) + + async def broadcast_visualization_data(self, visualization_data): + """Broadcast visualization data to all clients + + Args: + visualization_data: Visualization data, should include type field + """ + if not visualization_data or not isinstance(visualization_data, dict) or "type" not in visualization_data: + logger.warning(f"Invalid visualization data: {visualization_data}") + return + + logger.info(f"Broadcasting visualization data: {visualization_data['type']}") + message = { + "jsonrpc": "2.0", + "method": "notifications/visualization", + "params": { + "type": "visualization", + "data": visualization_data + } + } + await self.broadcast_message(message) + + async def send_visualization_data(self, session_id, visualization_data): + """Send visualization data to a specific client + + Args: + session_id: Session ID + visualization_data: Visualization data, should include type field + """ + if not visualization_data or not isinstance(visualization_data, dict) or "type" not in visualization_data: + logger.warning(f"Invalid visualization data: {visualization_data}") + return + + if session_id not in self.client_sessions: + logger.warning(f"Session does not exist: {session_id}") + return + + logger.info(f"Sending visualization data to session {session_id}: {visualization_data['type']}") + message = { + "jsonrpc": "2.0", + "method": "notifications/visualization", + "params": { + "type": "visualization", + "data": visualization_data + } + } + await self.client_sessions[session_id]["queue"].put(message) + + async def send_tool_result(self, session_id, tool_name, result_data, is_final=True): + """Send tool execution result to the client + + Args: + session_id: Session ID + tool_name: Tool name + result_data: Result data + is_final: Whether it is the final result + """ + if session_id not in self.client_sessions: + logger.warning(f"Session does not exist: {session_id}") + return + + logger.info(f"Sending tool result to session {session_id}: {tool_name}") + message = { + "jsonrpc": "2.0", + "method": "notifications/tool_result", + "params": { + "type": "tool_result", + "tool": tool_name, + "result": result_data, + "is_final": is_final + } + } + await self.client_sessions[session_id]["queue"].put(message) + + async def broadcast_message(self, message): + """Broadcast a message to all active sessions + + Args: + message: Message to broadcast + """ + # If no clients are connected, return immediately + if not self.client_sessions: + return + + # Create a copy of the session ID list so the original dictionary can be safely modified during iteration + session_ids = list(self.client_sessions.keys()) + + # Send message to all sessions + for session_id in session_ids: + try: + if session_id in self.client_sessions: # Check again, as session might have been removed during iteration + await self.client_sessions[session_id]["queue"].put(message) + except Exception as e: + logger.error(f"Error sending message to session {session_id}: {str(e)}") + + async def get_status(self): + """Get server status""" + return { + "status": "running", + "name": self.mcp_server.name, + "mode": "mcp_sse", + "clients": len(self.client_sessions) + } + + async def mcp_message(self, request: Request): + """Endpoint to receive client messages""" + try: + # Parse request parameters + session_id = self._get_session_id(request) + + # Check if session exists + if not session_id or session_id not in self.client_sessions: + logger.warning(f"Session does not exist: {session_id}") + return JSONResponse( + {"jsonrpc": "2.0", "error": {"code": -32000, "message": "Session does not exist or has expired"}}, + status_code=401 + ) + + # Update session last active time + self.client_sessions[session_id]["last_active"] = time.time() + + # Get request body + try: + body = await request.json() + logger.info(f"Received message [Session ID: {session_id}]: {json.dumps(body)}") + + # Process message + message_id = body.get("id", str(uuid.uuid4())) + + # Handle JSON-RPC 2.0 formatted commands + if "jsonrpc" not in body or body.get("jsonrpc") != "2.0" or "method" not in body: + return JSONResponse( + {"jsonrpc": "2.0", "id": message_id, "error": {"code": -32600, "message": "Invalid request, must be JSON-RPC 2.0 format"}}, + status_code=400 + ) + + # Get method and parameters + method = body.get("method") + params = body.get("params", {}) + + # Special handling for JSON-RPC formatted commands + if method == "initialize": + # Initialization request + logger.info(f"Processing initialize command [Session ID: {session_id}]") + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "protocolVersion": "2024-11-05", + "name": self.mcp_server.name, + "instructions": "This is an MCP server for Apache Doris database", + "serverInfo": { + "version": "0.1.0", + "name": "Doris MCP Server" + }, + "capabilities": { + "tools": { + "supportsStreaming": True, + "supportsProgress": True + }, + "resources": { + "supportsStreaming": False + }, + "prompts": { + "supported": True + } + } + } + } + await self.client_sessions[session_id]["queue"].put(response) + return JSONResponse( + {"status": "success"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + elif method == "mcp/listOfferings": + # List all available features + logger.info(f"Processing listOfferings command [Session ID: {session_id}]") + + # Get tool list + tools = await self.mcp_server.list_tools() + tools_json = [ + { + "name": tool.name if hasattr(tool, "name") else str(tool), + "description": tool.description if hasattr(tool, "description") else "", + "inputSchema": tool.parameters if hasattr(tool, "parameters") else { + "type": "object", + "properties": {}, + "required": [] + } + } + for tool in tools + ] + + # Get resource list + resources = await self.mcp_server.list_resources() + resources_json = [res.model_dump() if hasattr(res, "model_dump") else res for res in resources] + + # Get prompt template list + prompts = await self.mcp_server.list_prompts() + prompts_json = [prompt.model_dump() if hasattr(prompt, "model_dump") else prompt for prompt in prompts] + + # Build response + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "tools": tools_json, + "resources": resources_json, + "prompts": prompts_json + } + } + await self.client_sessions[session_id]["queue"].put(response) + return JSONResponse( + {"status": "success"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + elif method == "mcp/listTools" or method == "tools/list": + # List all tools + logger.info(f"Processing listTools command [Session ID: {session_id}]") + tools = await self.mcp_server.list_tools() + tools_json = [ + { + "name": tool.name if hasattr(tool, "name") else str(tool), + "description": tool.description if hasattr(tool, "description") else "", + "inputSchema": tool.parameters if hasattr(tool, "parameters") else { + "type": "object", + "properties": {}, + "required": [] + } + } + for tool in tools + ] + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "tools": tools_json + } + } + await self.client_sessions[session_id]["queue"].put(response) + return JSONResponse( + {"status": "success"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + elif method == "mcp/listResources": + # List all resources + logger.info(f"Processing listResources command [Session ID: {session_id}]") + resources = await self.mcp_server.list_resources() + resources_json = [res.model_dump() if hasattr(res, "model_dump") else res for res in resources] + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "resources": resources_json + } + } + await self.client_sessions[session_id]["queue"].put(response) + return JSONResponse( + {"status": "success"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + elif method == "mcp/callTool" or method == "tools/call": + # Call tool - special handling + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + if not tool_name: + error_response = { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": -32602, + "message": "Invalid params: tool name is required" + } + } + await self.client_sessions[session_id]["queue"].put(error_response) + return JSONResponse( + {"status": "error", "message": "Tool name is required"}, + status_code=400, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + # Get MCP instance + mcp = self.mcp_server + + # Check if it's a streaming tool call + stream_mode = "stream" in request.query_params or params.get("stream", False) + + logger.info(f"Calling tool [Session ID: {session_id}, Tool: {tool_name}, Args: {arguments}, Stream mode: {stream_mode}]") + + if stream_mode: + # Streaming tool call + logger.info(f"Using streaming response to handle tool call [Session ID: {session_id}, Tool: {tool_name}]") + + try: + # Find tool + tool_instance = None + for tool in await mcp.list_tools(): + if getattr(tool, 'name', '') == tool_name: + tool_instance = tool + break + + if not tool_instance: + raise Exception(f"Tool {tool_name} does not exist") + + # Define callback function + async def callback(content, metadata): + # Send partial result + partial_message = { + "jsonrpc": "2.0", + "id": message_id, + "partial": True, + "result": { + "content": content, + "metadata": metadata + } + } + # Put message into queue + await self.client_sessions[session_id]["queue"].put(partial_message) + + # If visualization data is included, broadcast to all clients + if metadata and "visualization" in metadata: + await self.broadcast_visualization_data(metadata["visualization"]) + + # Build argument dictionary + kwargs = dict(arguments) + kwargs['callback'] = callback + + # Execute tool call + # Fix: Do not call tool object directly, instead create async task to call call_tool method + # func = tool_instance.func if hasattr(tool_instance, 'func') else tool_instance + + # Start async task to execute streaming tool + # asyncio.create_task(self._execute_stream_tool(func, kwargs, message_id, session_id)) + # Modified to use call_tool method + asyncio.create_task(self._execute_stream_tool_wrapper(tool_name, kwargs, message_id, session_id, request)) + + # Return received confirmation + return JSONResponse( + {"status": "processing"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + except Exception as e: + logger.error(f"Streaming tool processing error: {str(e)}") + error_message = { + "jsonrpc": "2.0", + "id": message_id, + "success": False, + "error": str(e) + } + # Put error message into queue + await self.client_sessions[session_id]["queue"].put(error_message) + return JSONResponse( + {"status": "error", "message": str(e)}, + status_code=500, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + else: + # Non-streaming tool call + logger.info(f"Using standard response to handle tool call [Session ID: {session_id}, Tool: {tool_name}]") + + try: + # Find tool + tool_instance = None + for tool in await mcp.list_tools(): + if getattr(tool, 'name', '') == tool_name: + tool_instance = tool + break + + if not tool_instance: + error_response = { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": -32601, + "message": f"Tool '{tool_name}' not found" + } + } + await self.client_sessions[session_id]["queue"].put(error_response) + return JSONResponse( + {"status": "error", "message": f"Tool '{tool_name}' not found"}, + status_code=404, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + # Execute tool call + # Fix: Do not call tool object directly, instead call custom call_tool method + result = await self.call_tool(tool_name, arguments, request) + + # Special formatting for result + # If result is already in the correct format, use directly + if isinstance(result, dict) and "content" in result and isinstance(result["content"], list): + formatted_result = result + else: + # Otherwise, format into standard format + formatted_result = { + "content": [ + { + "type": "json", + "text": result if isinstance(result, str) else json.dumps(result, ensure_ascii=False) + } + ] + } + + # Build response + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": formatted_result + } + + # Put response into queue + await self.client_sessions[session_id]["queue"].put(response) + + # Return received confirmation + return JSONResponse( + {"status": "success"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + except Exception as e: + logger.error(f"Tool call error: {str(e)}", exc_info=True) + + # Build error response + if str(e).startswith('{"code":'): + # If it's a JSON formatted error, use directly + try: + error_obj = json.loads(str(e)) + error_response = { + "jsonrpc": "2.0", + "id": message_id, + "error": error_obj + } + except: + # If parsing fails, use standard format + error_response = { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": -32000, + "message": str(e) + } + } + else: + # Normal error string + error_response = { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": -32000, + "message": str(e) + } + } + + # Put error response into queue + await self.client_sessions[session_id]["queue"].put(error_response) + + # Return error status + return JSONResponse( + {"status": "error", "message": str(e)}, + status_code=500, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + else: + # Other message types, forward directly to MCP for handling + logger.info(f"Processing general message [Session ID: {session_id}]") + + try: + # Process message + # FastMCP object doesn't have process_message method, build response directly instead + # result = await mcp.process_message(body) + + # Build response + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "status": "ok", + "message": "Message received, but unable to process unrecognized message type" + } + } + except Exception as e: + logger.error(f"Error processing message [Session ID: {session_id}]: {str(e)}") + response = { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": -32000, + "message": f"Error processing message: {str(e)}" + } + } + + # Put response into queue + if response: + await self.client_sessions[session_id]["queue"].put(response) + + # Return received confirmation to HTTP request + return JSONResponse( + {"status": "received"}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + except Exception as e: + logger.error(f"Error processing message: {str(e)}") + # Send error response + error_response = { + "jsonrpc": "2.0", + "id": message_id if 'message_id' in locals() else str(uuid.uuid4()), + "error": { + "code": -32000, + "message": str(e) + } + } + + await self.client_sessions[session_id]["queue"].put(error_response) + return JSONResponse( + {"status": "error", "message": str(e)}, + status_code=500, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + except Exception as e: + logger.error(f"Error processing request: {str(e)}", exc_info=True) + return JSONResponse( + { + "jsonrpc": "2.0", + "id": "unknown", + "error": { + "code": -32000, + "message": f"Error processing request: {str(e)}" + } + }, + status_code=500, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "*" + } + ) + + async def _execute_stream_tool_wrapper(self, tool_name, kwargs, message_id, session_id, request): + """Wrapper for streaming tool calls + + Args: + tool_name: Tool name + kwargs: Function parameters + message_id: Message ID + session_id: Session ID + request: Request object + """ + try: + # Execute by calling the standard tool method + result = await self.call_tool(tool_name, kwargs, request) + + # Send completion message + final_message = { + "jsonrpc": "2.0", + "id": message_id, + "success": True, + "result": result + } + + # Check if session still exists + if session_id in self.client_sessions: + await self.client_sessions[session_id]["queue"].put(final_message) + logger.info(f"Streaming tool execution completed [Session ID: {session_id}, Message ID: {message_id}]") + else: + logger.warning(f"Streaming tool execution completed but session closed [Session ID: {session_id}]") + except Exception as e: + logger.error(f"Streaming tool execution failed [Session ID: {session_id}]: {str(e)}") + + # Send error message + error_message = { + "jsonrpc": "2.0", + "id": message_id, + "success": False, + "error": str(e) + } + + # Check if session still exists + if session_id in self.client_sessions: + await self.client_sessions[session_id]["queue"].put(error_message) + else: + logger.warning(f"Streaming tool execution failed but session closed [Session ID: {session_id}]") + + async def broadcast_tool_result(self, tool_name, result_data): + """Broadcast tool call result to all clients + + Args: + tool_name: Tool name + result_data: Result data + """ + logger.info(f"Broadcasting tool result: {tool_name}") + message = { + "jsonrpc": "2.0", + "method": "notifications/tool_result", + "params": { + "type": "tool_result", + "tool": tool_name, + "result": result_data + } + } + await self.broadcast_message(message) + + async def call_tool(self, tool_name, arguments, request): + """ + Calls the specified tool and returns the result + + Args: + tool_name: Tool name + arguments: Tool parameters + request: Original request object + + Returns: + Tool call result + """ + logger.info(f"Calling tool: {tool_name}, Parameters: {json.dumps(arguments, ensure_ascii=False)}") + + # Get recent query content, used to handle random_string parameter + recent_query = self._extract_recent_query(request) + + # Handle tool name mapping - Add support for standard name tools + tool_mapping = { + # --- Retained/New Tools --- + "exec_query": "mcp_doris_exec_query", + "get_table_schema": "mcp_doris_get_table_schema", + "get_db_table_list": "mcp_doris_get_db_table_list", + "get_db_list": "mcp_doris_get_db_list", + "get_table_comment": "mcp_doris_get_table_comment", + "get_table_column_comments": "mcp_doris_get_table_column_comments", + "get_table_indexes": "mcp_doris_get_table_indexes", + "get_recent_audit_logs": "mcp_doris_get_recent_audit_logs" + } + + # If it's a standard name, convert to MCP name + mapped_tool_name = tool_mapping.get(tool_name, tool_name) + + # Import tool functions from mcp_doris_tools + try: + # Import tool module + import doris_mcp_server.tools.mcp_doris_tools as mcp_tools + + # Get the corresponding tool function + tool_function = getattr(mcp_tools, mapped_tool_name, None) + + if not tool_function: + # If it doesn't exist in mcp_tools, try getting the tool using the MCP server instance + mcp = self.mcp_server + # Find the corresponding tool + for tool in await mcp.list_tools(): + if getattr(tool, 'name', '') == mapped_tool_name: + tool_function = tool + break + + if not tool_function: + raise ValueError(f"Tool not found: {tool_name} / {mapped_tool_name}") + + # Process common input parameter conversions + processed_args = self._process_tool_arguments(mapped_tool_name, arguments, recent_query) + + # Call the tool function + try: + # Log tool type and attribute information to help debugging + logger.debug(f"Tool function type: {type(tool_function)}") + logger.debug(f"Tool function attributes: {dir(tool_function)}") + + if callable(tool_function): + logger.debug("Tool function is callable, calling directly") + result = await tool_function(**processed_args) + elif hasattr(tool_function, 'run'): + logger.debug("Tool function has run method, calling run method") + result = await tool_function.run(**processed_args) + elif hasattr(tool_function, 'execute'): + logger.debug("Tool function has execute method, calling execute method") + result = await tool_function.execute(**processed_args) + elif hasattr(tool_function, 'call'): + logger.debug("Tool function has call method, calling call method") + result = await tool_function.call(**processed_args) + elif hasattr(tool_function, '__call__'): + logger.debug("Tool function has __call__ method, calling __call__ method") + result = await tool_function.__call__(**processed_args) + else: + # If it's a dict type, try getting the function from it + if isinstance(tool_function, dict) and 'function' in tool_function: + logger.debug("Tool is a dictionary type, trying to get 'function' key") + actual_func = tool_function['function'] + if callable(actual_func): + result = await actual_func(**processed_args) + else: + raise ValueError(f"Function in dictionary is not callable: {type(actual_func)}") + else: + raise ValueError(f"Unsupported tool type: {type(tool_function)}, Attributes: {dir(tool_function)}") + except Exception as e: + logger.error(f"Failed to call tool function: {str(e)}", exc_info=True) + raise ValueError(f"Error calling tool: {str(e)}") + + # Return tool execution result + return result + except AttributeError as e: + logger.error(f"Tool function does not exist: {mapped_tool_name}, Error: {str(e)}") + raise ValueError(f"Tool function does not exist: {mapped_tool_name}") + except Exception as e: + logger.error(f"Error calling tool: {str(e)}", exc_info=True) + raise ValueError(f"Error calling tool: {str(e)}") + + def _process_tool_arguments(self, tool_name, arguments, recent_query): + """ + Process tool parameters, supporting special handling logic + + Args: + tool_name: Tool name (MCP internal name, e.g., mcp_doris_...) + arguments: Original parameters + recent_query: Recent query content + + Returns: + Processed parameter dictionary + """ + # Copy parameters to avoid modifying the original object + processed_args = dict(arguments) + + # Handle potential random_string parameter as fallback + if "random_string" in processed_args and tool_name.startswith("mcp_doris_"): + random_string = processed_args.pop("random_string", "") + logger.debug(f"Processing random_string parameter for tool {tool_name}: '{random_string}'") + + # 1. For exec_query + if tool_name == "mcp_doris_exec_query": + if not processed_args.get("sql"): + sql_fallback = random_string or recent_query + if sql_fallback: + if not random_string and recent_query: + import re + sql_matches = re.findall(r'```sql\s*([\s\S]+?)\s*```', recent_query) + if sql_matches: + sql_fallback = sql_matches[0].strip() + + if sql_fallback: + logger.info(f"Using random_string/recent_query as SQL for exec_query: {sql_fallback[:100]}...") + processed_args["sql"] = sql_fallback + else: + logger.warning(f"exec_query missing sql parameter, and random_string/recent_query is empty or SQL cannot be extracted") + else: + logger.warning(f"exec_query missing sql parameter, and both random_string and recent_query are empty") + + # 2. For tools requiring table_name + elif tool_name in [ + "mcp_doris_get_table_schema", + "mcp_doris_get_table_comment", + "mcp_doris_get_table_column_comments", + "mcp_doris_get_table_indexes" + ]: + if not processed_args.get("table_name"): + table_fallback = random_string + if table_fallback: + logger.info(f"Using random_string/recent_query as table_name for {tool_name}: {table_fallback}") + processed_args["table_name"] = table_fallback + else: + logger.warning(f"{tool_name} missing table_name parameter, and random_string/recent_query is empty or table name cannot be extracted") + + # 3. Other tools + else: + logger.debug(f"Tool {tool_name} does not apply random_string fallback logic, or logic is undefined.") + + # Ensure return is outside the main if block + return processed_args + + def _extract_recent_query(self, request: Request) -> Optional[str]: + """ + Extract the most recent user query from the request + + Args: + request: Request object + + Returns: + Optional[str]: The most recent user query, or None if not found + """ + try: + # Try to extract message history from request body + body = None + body_bytes = getattr(request, "_body", None) + if body_bytes: + try: + body = json.loads(body_bytes) + except: + pass + + if not body: + body = getattr(request, "_json", {}) + + # Find the most recent user message from message history + messages = body.get("params", {}).get("messages", []) + if messages: + # Iterate messages in reverse to find the most recent user message + for msg in reversed(messages): + if msg.get("role") == "user": + return msg.get("content", "") + + # If not found in message history, try extracting from the original message + message = body.get("params", {}).get("message", {}) + if message and message.get("role") == "user": + return message.get("content", "") + + return None + except Exception as e: + logger.error(f"Error extracting recent query: {str(e)}") + return None + + def format_tool_result(self, result): + """ + Format tool call result into a unified format + + Args: + result: Original tool call result + + Returns: + Formatted result + """ + try: + # If result is already a dictionary, return directly + if isinstance(result, dict): + return result + + # If it's a string, try parsing as JSON + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError: + # Pure text result + return {"content": result} + + # If it's another type, convert to string + return {"content": str(result)} + + except Exception as e: + logger.error(f"Error formatting tool result: {str(e)}") + return {"error": str(e)} + + def _get_session_id(self, request: Request) -> str: + """ + Get session ID from the request + + Tries to get session ID from the following locations (in priority order): + 1. Query parameter session_id + 2. session_id field in request body + 3. X-Session-ID header + + Args: + request: Request object + + Returns: + str: Session ID, or None if not found + """ + # Get from query parameter + session_id = request.query_params.get("session_id") + if session_id: + return session_id + + # Try getting from request body + try: + body = getattr(request, "_json", None) + if not body: + body_bytes = getattr(request, "_body", None) + if body_bytes: + try: + body = json.loads(body_bytes) + except: + pass + + if body and isinstance(body, dict) and "session_id" in body: + return body["session_id"] + except: + pass + + # Get from request header + session_id = request.headers.get("X-Session-ID") + if session_id: + return session_id + + return None diff --git a/doris_mcp_server/streamable_server.py b/doris_mcp_server/streamable_server.py new file mode 100644 index 0000000..63fe5e7 --- /dev/null +++ b/doris_mcp_server/streamable_server.py @@ -0,0 +1,912 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Doris MCP Streamable HTTP Server Implementation + +Implements the MCP 2025-03-26 Streamable HTTP specification. +Uses a unified /mcp endpoint for GET, POST, DELETE, OPTIONS. +Manages sessions using Mcp-Session-Id header. +""" + +import asyncio +import json +import uuid +import logging +import time +from typing import Any, Optional, Dict, List +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from sse_starlette.sse import EventSourceResponse + +# Use a distinct logger name +logger = logging.getLogger("doris-mcp-streamable") + +# Special marker for closing streams +STREAM_END_MARKER = "__MCP_STREAM_END__" + +class DorisMCPStreamableServer: + """Doris MCP Streamable HTTP Server""" + + def __init__(self, mcp_server, app: FastAPI): + """ + Initializes the Doris MCP Streamable HTTP server. + + Args: + mcp_server: The shared FastMCP server instance. + app: The main FastAPI application instance. + """ + self.mcp_server = mcp_server + self.app = app # We'll add routes to this app + + # Note: CORS middleware should be added only once in main.py usually. + # If added here, ensure it doesn't conflict or duplicate. + # For separation, we might let main.py handle CORS entirely. + + # Client session management for Streamable HTTP clients + # key: session_id (from Mcp-Session-Id header) + # value: { + # "created_at": timestamp, + # "last_active": timestamp, + # "request_queues": { request_id: asyncio.Queue }, # For POST /mcp request streams + # "general_sse_queues": List[asyncio.Queue] # For GET /mcp server push streams + # } + self.client_sessions: Dict[str, Dict[str, Any]] = {} + + # Setup the unified MCP endpoint + self._setup_streamable_http_routes() + + # Register session cleanup task if this instance manages lifespan independently + # Usually, startup events are tied to the main app lifespan managed in main.py + # We might not need @app.on_event("startup") here if main.py handles it. + # Let's assume main.py handles the cleanup task initiation. + + def _setup_streamable_http_routes(self): + """Sets up the unified /mcp endpoint for Streamable HTTP. + Uses a distinct tag for API docs. + """ + + @self.app.api_route("/mcp", methods=["GET", "POST", "DELETE", "OPTIONS"], tags=["Streamable HTTP"]) + async def mcp_endpoint_handler(request: Request): + """Handles GET, POST, DELETE, OPTIONS for the /mcp endpoint.""" + + # 1. Handle OPTIONS (CORS preflight) + if request.method == "OPTIONS": + # Assuming CORS headers are handled by middleware in main.py + # If not, provide necessary headers here. + # This minimal response might suffice if middleware handles the rest + logger.debug("Handling OPTIONS request for /mcp") + # Return basic OK allowing exposed headers if middleware handles the rest + return JSONResponse({}, headers={"Access-Control-Expose-Headers": "Mcp-Session-Id"}) + + # Session ID from header is required for most methods + session_id = request.headers.get("Mcp-Session-Id") + + # 2. Handle DELETE (Terminate Session) + if request.method == "DELETE": + if not session_id: + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32600, "message": "Mcp-Session-Id header is required for DELETE"}}, status_code=400) + + logger.info(f"Handling DELETE request for session [Session ID: {session_id}]") + session_data = self.client_sessions.pop(session_id, None) + if session_data: + await self._cleanup_session_resources(session_id, session_data) + return JSONResponse({}, status_code=204) # No Content + else: + logger.warning(f"Attempted DELETE on non-existent session: {session_id}") + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32001, "message": "Session not found"}}, status_code=404) + + # 3. Handle GET (Server Push SSE Stream) + if request.method == "GET": + if not session_id: + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32000, "message": "Mcp-Session-Id header is required for GET streams"}}, status_code=400) + if session_id not in self.client_sessions: + # Note: Unlike legacy SSE, GET here assumes session exists. + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32001, "message": "Session not found. Initialize first."}}, status_code=404) + + accept_header = request.headers.get("Accept", "") + if "text/event-stream" not in accept_header: + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32600, "message": "Accept header must include text/event-stream for GET"}}, status_code=406) + + # TODO: Handle Last-Event-ID for stream recovery? + + logger.info(f"Handling GET request, establishing server push SSE stream [Session ID: {session_id}]") + + push_queue = asyncio.Queue() + if self.client_sessions[session_id].get("general_sse_queues") is None: + self.client_sessions[session_id]["general_sse_queues"] = [] + self.client_sessions[session_id]["general_sse_queues"].append(push_queue) + self.client_sessions[session_id]["last_active"] = time.time() + + return EventSourceResponse(self._create_general_sse_generator(session_id, push_queue), media_type="text/event-stream") + + # 4. Handle POST (Client Messages & Initialize) + if request.method == "POST": + accept_header = request.headers.get("Accept", "") + content_type = request.headers.get("Content-Type", "") + + body = {} + try: + if "application/json" not in content_type: + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Content-Type must be application/json"}}, status_code=415) + body = await request.json() + if isinstance(body, list): return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32600, "message": "Batch requests not supported"}}, status_code=400) + if not isinstance(body, dict): return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Invalid JSON received"}}, status_code=400) + + method = body.get("method") + message_id = body.get("id") # Can be None for notifications + + # Handle Initialize request (does not require Mcp-Session-Id header) + if method == "initialize": + if "application/json" not in accept_header: + return JSONResponse({"jsonrpc": "2.0", "id": message_id, "error": {"code": -32600, "message": "Accept header must include application/json for initialize"}}, status_code=406) + return await self._handle_initialize(request, body, message_id) + + # Handle other POST requests (require Mcp-Session-Id) + else: + if not session_id: + return JSONResponse({"jsonrpc": "2.0", "id": message_id, "error": {"code": -32000, "message": "Mcp-Session-Id header is required for this request"}}, status_code=400) + if session_id not in self.client_sessions: + return JSONResponse({"jsonrpc": "2.0", "id": message_id, "error": {"code": -32001, "message": "Session not found"}}, status_code=404) + # Check Accept header for non-initialize POST + if not ("application/json" in accept_header and "text/event-stream" in accept_header): + return JSONResponse({"jsonrpc": "2.0", "id": message_id, "error": {"code": -32600, "message": "Accept header must include application/json and text/event-stream for POST"}}, status_code=406) + + self.client_sessions[session_id]["last_active"] = time.time() + return await self._handle_client_post(request, body, session_id, message_id) + + except json.JSONDecodeError: + return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error - Invalid JSON received"}}, status_code=400) + except Exception as e: + logger.error(f"Unexpected error handling POST /mcp: {str(e)}", exc_info=True) + error_id = body.get("id") if isinstance(body, dict) else None + return JSONResponse({"jsonrpc": "2.0", "id": error_id, "error": {"code": -32000, "message": "Internal server error"}}, status_code=500) + + # Fallback for other methods like PUT, PATCH etc. + return JSONResponse({"error": "Method Not Allowed"}, status_code=405) + + async def _handle_initialize(self, request: Request, body: Dict, message_id: Any): + """Handles the 'initialize' method call via POST /mcp.""" + logger.info("Handling Streamable HTTP initialize request") + # Optional: Validate params in body if needed + # params = body.get("params", {}) + + new_session_id = str(uuid.uuid4()) + logger.info(f"Created new Streamable HTTP session [Session ID: {new_session_id}]") + + self.client_sessions[new_session_id] = { + "created_at": time.time(), + "last_active": time.time(), + # No transport_type needed here as this class *is* the streamable server + "request_queues": {}, # Initialize request queues dict + "general_sse_queues": [] # Initialize general queues list + } + + # Build InitializeResult based on spec + initialize_result = { + "protocolVersion": "2025-03-26", + "name": self.mcp_server.name, + "instructions": "Apache Doris MCP Server (Streamable HTTP Mode)", + "serverInfo": { "version": "0.2.0", "name": "Doris MCP Streamable Server" }, # Adjust as needed + "capabilities": { + "tools": { "supportsStreaming": True, "supportsProgress": True }, + "resources": { "supportsStreaming": False }, # Example capability + "prompts": { "supported": True }, # Example capability + "session": { "supported": True } + } + } + response_body = { + "jsonrpc": "2.0", + "id": message_id, + "result": initialize_result + } + + # Return JSON response with Mcp-Session-Id header + return JSONResponse( + content=response_body, + media_type="application/json", + headers={"Mcp-Session-Id": new_session_id} + ) + + async def _handle_client_post(self, request: Request, body: Dict, session_id: str, message_id: Any): + """Handles non-initialize POST requests (notifications, responses, method calls).""" + method = body.get("method") + + # Handle Notifications/Responses from client + is_notification = "method" in body and "id" not in body + is_response = "result" in body or "error" in body + if is_notification or is_response: + logger.info(f"Received Streamable HTTP notification/response [Session ID: {session_id}] - Processing needed? (Ignoring for now)") + # TODO: If the server sends requests that expect responses, process is_response here. + # For now, just acknowledge client notifications/responses. + return JSONResponse({}, status_code=202) # Accepted + + # Handle Requests from client (method call) + if "method" in body and "id" in body: + logger.info(f"Received Streamable HTTP request [Session ID: {session_id}, ID: {message_id}, Method: {method}]") + params = body.get("params", {}) + stream_required = params.get("stream", False) if method in ["tools/call", "mcp/callTool"] else False + + if stream_required: + # --- Return SSE stream for response parts --- + logger.info(f"Using SSE stream for request [Session ID: {session_id}, ID: {message_id}]") + response_queue = asyncio.Queue() + # Ensure request_queues exists (should have been created during initialize) + if self.client_sessions[session_id].get("request_queues") is None: + logger.error(f"Session {session_id} is missing 'request_queues' dictionary!") + # Handle this inconsistency, maybe return an error + return JSONResponse({"jsonrpc": "2.0", "id": message_id, "error": {"code": -32000, "message": "Internal server error: Session state inconsistent"}}, status_code=500) + self.client_sessions[session_id]["request_queues"][message_id] = response_queue + + # Start background task to process and put results in the queue + asyncio.create_task(self._process_request_and_respond( + request, body, session_id, message_id, response_queue, is_stream=True + )) + + # Return EventSourceResponse using the request-specific queue + return EventSourceResponse(self._create_request_sse_generator(session_id, message_id, response_queue), media_type="text/event-stream") + else: + # --- Return single JSON response --- + logger.info(f"Using JSON response for request [Session ID: {session_id}, ID: {message_id}]") + try: + # Process the request directly and get the result/error payload + result_or_error_payload = await self._process_request_and_respond( + request, body, session_id, message_id, None, is_stream=False + ) + # This function now returns the final JSON body or raises HTTPException + return JSONResponse(content=result_or_error_payload, media_type="application/json") + except HTTPException as http_exc: + # Format HTTPException details into JSON-RPC error + return JSONResponse( + {"jsonrpc": "2.0", "id": message_id, "error": {"code": -32000, "message": http_exc.detail}}, + status_code=http_exc.status_code + ) + except Exception as e: + # Catch unexpected errors during synchronous processing + logger.error(f"Error processing non-stream request [Session ID: {session_id}, ID: {message_id}]: {str(e)}", exc_info=True) + error_response = {"jsonrpc": "2.0", "id": message_id, "error": {"code": -32000, "message": f"Internal server error: {str(e)}"}} + return JSONResponse(content=error_response, status_code=500) + else: + # Invalid JSON-RPC format (e.g., missing method or id for a request) + return JSONResponse({"jsonrpc": "2.0", "id": message_id, "error": {"code": -32600, "message": "Invalid JSON-RPC request format"}}, status_code=400) + + # === Generator Functions for SSE Streams === + + async def _create_general_sse_generator(self, session_id: str, queue: asyncio.Queue): + """Generator for GET /mcp server push streams.""" + queue_removed = False + try: + while True: + try: + if session_id not in self.client_sessions: + logger.warning(f"General SSE stream generator: Session {session_id} closed.") + break + + message = await asyncio.wait_for(queue.get(), timeout=60.0) + + if message == STREAM_END_MARKER: + logger.debug(f"General SSE stream received end marker [Session ID: {session_id}]") + break + + if isinstance(message, dict) and ("result" in message or "error" in message) and "id" in message: + logger.warning(f"Attempted to send response on GET stream, blocked [Session ID: {session_id}]: {message}") + queue.task_done() + continue + + # TODO: Event ID for recovery? + yield {"event": "message", "data": json.dumps(message)} + queue.task_done() + + except asyncio.TimeoutError: + if session_id not in self.client_sessions: + logger.warning(f"General SSE stream generator (timeout): Session {session_id} closed.") + break + yield {"event": "ping", "data": "keepalive"} + continue + except asyncio.CancelledError: + logger.info(f"General SSE stream cancelled [Session ID: {session_id}]") + raise + except Exception as e: + logger.error(f"General SSE stream error [Session ID: {session_id}]: {str(e)}", exc_info=True) + break + finally: + logger.info(f"General SSE stream ended [Session ID: {session_id}]") + if not queue_removed and session_id in self.client_sessions: + session = self.client_sessions[session_id] + if session.get("general_sse_queues") is not None: + try: + session["general_sse_queues"].remove(queue) + queue_removed = True + logger.debug(f"General SSE queue removed from session [Session ID: {session_id}]") + except ValueError: + logger.warning(f"Failed to remove general SSE queue (not found) [Session ID: {session_id}]") + except Exception as ce: + logger.error(f"Error removing general SSE queue [Session ID: {session_id}]: {ce}") + while not queue.empty(): + try: queue.get_nowait(); queue.task_done() + except asyncio.QueueEmpty: break + + async def _create_request_sse_generator(self, session_id: str, request_id: Any, queue: asyncio.Queue): + """Generator for POST /mcp request-response streams.""" + queue_removed = False + try: + while True: + try: + if session_id not in self.client_sessions or \ + request_id not in self.client_sessions.get(session_id, {}).get("request_queues", {}): + logger.warning(f"Request SSE stream generator: Session/Request queue closed [Session ID: {session_id}, Request ID: {request_id}]") + break + + message = await asyncio.wait_for(queue.get(), timeout=120.0) # Longer timeout for requests? + + if message == STREAM_END_MARKER: + logger.debug(f"Request SSE stream received end marker [Session ID: {session_id}, Request ID: {request_id}]") + break + + # TODO: Event ID for parts? + yield {"event": "message", "data": json.dumps(message)} + queue.task_done() + + except asyncio.TimeoutError: + if session_id not in self.client_sessions or \ + request_id not in self.client_sessions.get(session_id, {}).get("request_queues", {}): + logger.warning(f"Request SSE stream generator (timeout): Session/Request queue closed [Session ID: {session_id}, Request ID: {request_id}]") + break + logger.debug(f"Request SSE stream timed out waiting for message/end [Session ID: {session_id}, Request ID: {request_id}]") + # Unlike general stream, timeout here might indicate an issue or just long processing. + # Continue waiting for the STREAM_END_MARKER. + continue + except asyncio.CancelledError: + logger.info(f"Request SSE stream cancelled [Session ID: {session_id}, Request ID: {request_id}]") + raise + except Exception as e: + logger.error(f"Request SSE stream error [Session ID: {session_id}, Request ID: {request_id}]: {str(e)}", exc_info=True) + break + finally: + logger.info(f"Request SSE stream ended [Session ID: {session_id}, Request ID: {request_id}]") + if not queue_removed and session_id in self.client_sessions: + session = self.client_sessions[session_id] + if session.get("request_queues") is not None: + if session["request_queues"].pop(request_id, None): + queue_removed = True + logger.debug(f"Request SSE queue removed from session [Session ID: {session_id}, Request ID: {request_id}]") + else: + logger.warning(f"Failed to remove request SSE queue (not found) [Session ID: {session_id}, Request ID: {request_id}]") + while not queue.empty(): + try: queue.get_nowait(); queue.task_done() + except asyncio.QueueEmpty: break + + # === Core Request Processing Logic === + + async def _process_request_and_respond( + self, request: Request, body: Dict, session_id: str, message_id: Any, + response_queue: Optional[asyncio.Queue], # Queue ONLY for streaming responses + is_stream: bool # True if response should go via SSE queue + ): + """Processes client method calls and prepares response/error payload or sends to queue. + Returns payload for non-streaming, returns None for streaming (uses queue). + Raises HTTPException for non-streaming errors that need specific status codes. + """ + logger.info(f"Entering _process_request_and_respond for method '{body.get('method')}'...") + method = body.get("method") + params = body.get("params", {}) + response_payload = None # Holds the 'result' or 'error' part of JSON-RPC + + try: + # --- Handle Method Calls --- + if method == "mcp/listOfferings": + tools = await self.mcp_server.list_tools() + tools_json = self._format_tools(tools) + resources = await self.mcp_server.list_resources() + resources_json = self._format_resources(resources) + prompts = await self.mcp_server.list_prompts() + prompts_json = self._format_prompts(prompts) + response_payload = {"tools": tools_json, "resources": resources_json, "prompts": prompts_json} + + elif method == "mcp/listTools" or method == "tools/list": + tools = await self.mcp_server.list_tools() + response_payload = {"tools": self._format_tools(tools)} + + elif method == "mcp/listResources": + resources = await self.mcp_server.list_resources() + response_payload = {"resources": self._format_resources(resources)} + + elif method == "mcp/listPrompts": + prompts = await self.mcp_server.list_prompts() + response_payload = {"prompts": self._format_prompts(prompts)} + + elif method == "mcp/callTool" or method == "tools/call": + tool_name = params.get("name") + arguments = params.get("arguments", {}) + if not tool_name: + # For non-streaming, raise HTTPException; for streaming, send error via queue + error_detail = "Invalid params: tool name ('name') is required" + if is_stream and response_queue: + error_resp = {"jsonrpc": "2.0", "id": message_id, "error": {"code": -32602, "message": error_detail}} + await response_queue.put(error_resp) + # No return here for stream, let finally handle end marker + else: + raise HTTPException(status_code=400, detail=error_detail) + return # Exit after handling error + + # --- Tool Calling --- + if is_stream and response_queue: + # Background task handles putting results/errors in queue + logger.info(f"Launching stream tool task [Session: {session_id}, Req: {message_id}, Tool: {tool_name}]") + asyncio.create_task(self._execute_stream_tool_wrapper( + tool_name, arguments, message_id, session_id, request, response_queue + )) + # Returns None, caller (_handle_client_post) returns EventSourceResponse + return + else: + # Execute tool directly for non-streaming response + logger.info(f"Executing non-stream tool [Session: {session_id}, Req: {message_id}, Tool: {tool_name}]") + # Note: call_tool now raises ValueError on internal errors + result = await self.call_tool(tool_name, arguments, request, None) # No callback needed + logger.debug(f"Raw result from non-stream call_tool: {result}") + response_payload = self._format_tool_call_result(result) + else: + # Method not found + error_detail = f"Method not found: {method}" + if is_stream and response_queue: + error_resp = {"jsonrpc": "2.0", "id": message_id, "error": {"code": -32601, "message": error_detail}} + await response_queue.put(error_resp) + else: + raise HTTPException(status_code=405, detail=error_detail) + return # Exit after handling error + + # --- Prepare final response payload (only if not streaming and successful) --- + if response_payload is not None: + final_response = {"jsonrpc": "2.0", "id": message_id, "result": response_payload} + if is_stream and response_queue: # Should not happen if response_payload is set + logger.error("Logic error: response_payload set for streaming call?") + await response_queue.put(final_response) # Send anyway? + elif not is_stream: + logger.debug(f"Returning successful non-stream payload for {method}") + return final_response # Return dict for JSONResponse + + except Exception as e: + # Handles errors raised by call_tool (ValueError) or other unexpected issues + logger.error(f"Error processing request [Session: {session_id}, Req: {message_id}, Method: {method}]: {str(e)}", exc_info=True) + error_code = -32000 + error_message = f"Internal server error: {str(e)}" + status_code = 500 # Default for unexpected errors + + if isinstance(e, HTTPException): + # If it was an HTTPException raised earlier (e.g., 400, 405) + error_message = e.detail + status_code = e.status_code + error_code = -32000 # Keep generic JSON-RPC code for now + elif isinstance(e, ValueError): + # Errors from call_tool (tool not found, execution error) + error_message = str(e) + status_code = 500 # Treat tool execution errors as internal server errors + error_code = -32000 # Or a custom tool error code? + + error_response_payload = {"code": error_code, "message": error_message} + + if is_stream and response_queue: + # Send error via queue for streaming calls + final_error_response = {"jsonrpc": "2.0", "id": message_id, "error": error_response_payload} + logger.debug(f"Putting error response into stream queue [Session: {session_id}, Req: {message_id}]") + await response_queue.put(final_error_response) + # Returns None, let finally send end marker + return + else: + # For non-streaming, raise HTTPException to set status code + logger.debug(f"Raising HTTPException for non-stream error (Status: {status_code})") + raise HTTPException(status_code=status_code, detail=error_message) + + finally: + # If this was a streaming call, ensure the end marker is sent. + # This runs even if the processing returns early (e.g., after launching task or handling error). + if is_stream and response_queue: + logger.debug(f"Putting stream end marker [Session: {session_id}, Req: {message_id}]") + await response_queue.put(STREAM_END_MARKER) + + + async def _execute_stream_tool_wrapper( + self, tool_name: str, arguments: Dict, message_id: Any, session_id: str, + request: Request, response_queue: asyncio.Queue + ): + """Wraps stream-capable tool calls, handles callback, puts results/errors into queue.""" + logger.info(f"Entering _execute_stream_tool_wrapper for tool '{tool_name}'...") + try: + logger.debug(f"Executing stream tool wrapper [Session: {session_id}, Req: {message_id}, Tool: {tool_name}]") + + async def stream_callback(content, metadata=None): + logger.debug(f"Stream callback received content [Session: {session_id}, Req: {message_id}]") + partial_result_formatted = self._format_tool_call_result(content) + + # Check session/queue validity before putting + if session_id not in self.client_sessions or \ + message_id not in self.client_sessions.get(session_id, {}).get("request_queues", {}): + logger.warning(f"Stream callback: Session/Queue closed, cannot send partial result [Session: {session_id}, Req: {message_id}]") + return + + # Send progress notification + progress_notification = { + "jsonrpc": "2.0", + "method": "tools/progress", + "params": { + "requestId": message_id, + "toolName": tool_name, + "progress": partial_result_formatted, + } + } + try: + await response_queue.put(progress_notification) + except Exception as e: + logger.error(f"Stream callback failed to send progress: {str(e)}") + + # Handle visualization data + if metadata and "visualization" in metadata: + await self.send_visualization_data(session_id, message_id, metadata["visualization"]) + + # --- Call Tool --- + kwargs = dict(arguments) + # Simplification: Assume tool supports callback if streaming requested + kwargs['callback'] = stream_callback + + # call_tool handles its own internal errors and raises ValueError + result = await self.call_tool(tool_name, kwargs, request, stream_callback) + logger.debug(f"Stream wrapper received final result from call_tool: {result}") + + # --- Send Final Result --- + if session_id not in self.client_sessions or \ + message_id not in self.client_sessions.get(session_id, {}).get("request_queues", {}): + logger.warning(f"Stream tool finished but Session/Queue closed [Session: {session_id}, Req: {message_id}]") + return # Cannot send final result + + final_result_formatted = self._format_tool_call_result(result) + final_message = { + "jsonrpc": "2.0", + "id": message_id, + "result": final_result_formatted + } + logger.debug(f"Putting final stream result into queue [Session: {session_id}, Req: {message_id}]") + await response_queue.put(final_message) + logger.info(f"Stream tool execution successful [Session: {session_id}, Req: {message_id}]") + + except Exception as e: + # Catches errors from call_tool (ValueError) or other wrapper issues + logger.error(f"Error during stream tool execution wrapper [Session: {session_id}, Req: {message_id}]: {str(e)}", exc_info=True) + # Check session/queue validity before sending error + if session_id not in self.client_sessions or \ + message_id not in self.client_sessions.get(session_id, {}).get("request_queues", {}): + logger.warning(f"Stream tool failed but Session/Queue closed [Session: {session_id}, Req: {message_id}]") + return # Cannot send error + + error_code = -32000 + error_message = f"Tool execution error: {str(e)}" + if isinstance(e, ValueError): + error_code = -32602 # Or -32000? + error_message = str(e) + + error_response = { + "jsonrpc": "2.0", + "id": message_id, + "error": { "code": error_code, "message": error_message } + } + try: + await response_queue.put(error_response) + except Exception as qe: + logger.error(f"Failed to put error response into stream queue: {qe}") + # No finally block needed here, handled by _process_request_and_respond + + + async def call_tool(self, tool_name, arguments, request, callback: Optional[callable] = None): + """Finds and executes the target tool function/method. + Raises ValueError on tool not found or execution error. + """ + logger.info(f"Entering call_tool for tool '{tool_name}'...") + # Log args excluding callback + log_args = {k: v for k, v in arguments.items() if k != 'callback'} + logger.info(f"Executing tool: {tool_name}, Args: {json.dumps(log_args, ensure_ascii=False, default=str)}") + + recent_query = self._extract_recent_query(request) + # Tool mapping might be needed if client uses different names + tool_mapping = { + # Example: "clientFacingName": "internalFunctionName" + "status": "mcp_doris_status", + "health": "mcp_doris_health", + # Add other mappings if needed, ensure consistency with tool_initializer + "nl2sql_query": "mcp_doris_nl2sql_query", + "nl2sql_query_stream": "mcp_doris_nl2sql_query_stream", + "list_database_tables": "mcp_doris_list_database_tables", + "explain_table": "mcp_doris_explain_table", + "get_nl2sql_status": "mcp_doris_get_nl2sql_status", + "refresh_metadata": "mcp_doris_refresh_metadata", + "sql_optimize": "mcp_doris_sql_optimize", + "fix_sql": "mcp_doris_fix_sql", + "count_chars": "mcp_doris_count_chars", + "exec_query": "mcp_doris_exec_query", + "get_schema_list": "mcp_doris_get_schema_list", # Deprecated? + "save_metadata": "mcp_doris_save_metadata", # Likely internal + "get_metadata": "mcp_doris_get_metadata", # Likely internal + "analyze_query_result": "mcp_doris_analyze_query_result", # Internal? + "generate_sql": "mcp_doris_generate_sql", # Likely internal + "explain_sql": "mcp_doris_explain_sql", # Internal? + "modify_sql": "mcp_doris_modify_sql", # Internal? + "parse_query": "mcp_doris_parse_query", # Internal? + "identify_query_type": "mcp_doris_identify_query_type", # Internal? + "validate_sql_syntax": "mcp_doris_validate_sql_syntax", # Internal? + "check_sql_security": "mcp_doris_check_sql_security", # Internal? + "find_similar_examples": "mcp_doris_find_similar_examples", # Internal? + "find_similar_history": "mcp_doris_find_similar_history", # Internal? + "calculate_query_similarity": "mcp_doris_calculate_query_similarity", # Internal? + "adapt_similar_query": "mcp_doris_adapt_similar_query", # Internal? + "get_nl2sql_prompt": "mcp_doris_get_nl2sql_prompt" # Internal? + } + mapped_tool_name = tool_mapping.get(tool_name, tool_name) + + try: + # 1. Find the registered tool instance/function from FastMCP + tool_instance = None + mcp = self.app.state.mcp if hasattr(self.app.state, 'mcp') else self.mcp_server + registered_tools = await mcp.list_tools() + for tool in registered_tools: + # The tool object returned by list_tools might be the wrapper function + # defined in tool_initializer. We need its name. + tool_registered_name = getattr(tool, 'name', getattr(tool, '__name__', None)) + if tool_registered_name == tool_name: # Match against the name used in @mcp.tool + tool_instance = tool # This is likely the wrapper function itself + logger.debug(f"Found registered tool wrapper: {tool_registered_name}") + break + + if not tool_instance: + # Fallback: Try importing directly (less ideal as it bypasses registration) + logger.warning(f"Tool '{tool_name}' not found in registered tools, trying direct import of {mapped_tool_name}") + try: + import doris_mcp_server.tools.mcp_doris_tools as mcp_tools + tool_instance = getattr(mcp_tools, mapped_tool_name, None) + if not tool_instance or not callable(tool_instance): + raise ValueError(f"Tool function {mapped_tool_name} not found or not callable in mcp_doris_tools.") + logger.debug(f"Using directly imported tool function: {mapped_tool_name}") + # If using direct import, FastMCP context (ctx) is not available + # We need to pass args directly + processed_args = self._process_tool_arguments(mapped_tool_name, arguments, recent_query) + # Inject callback if provided and applicable + if callback and mapped_tool_name.endswith("_stream"): + processed_args['callback'] = callback + elif callback: + processed_args.pop('callback', None) + result = await tool_instance(**processed_args) + logger.debug(f"Raw result from directly imported tool '{mapped_tool_name}': {result}") + return result + + except (ImportError, AttributeError, ValueError) as import_err: + logger.error(f"Failed to find or import tool: {tool_name} / {mapped_tool_name}. Error: {import_err}") + raise ValueError(f"Tool '{tool_name}' not found or failed to import.") from import_err + + # 2. If found via registration, execute using FastMCP's mechanism (if possible) + # or simulate the context passing if tool_instance is the wrapper. + # The wrapper expects a Context object. + logger.debug(f"Executing registered tool wrapper '{tool_name}'") + # We need to manually create a mock or simplified Context if FastMCP doesn't handle this automatically + # For simplicity, let's try passing parameters directly if the wrapper handles it. + # Ideally, FastMCP would handle the execution via mcp.call_tool(tool_name, params=...) if available. + # Let's assume the wrapper function handles **kwargs or a Context object. + + # Create a pseudo-context or just pass params + # Method 1: Pass params directly (assuming wrapper handles it) + # processed_args = self._process_tool_arguments(mapped_tool_name, arguments, recent_query) + # if callback: + # processed_args['callback'] = callback + # result = await tool_instance(**processed_args) # This likely won't work if it expects Context + + # Method 2: Create a Context-like object (Requires Context class import) + # from mcp.server.fastmcp import Context # Make sure imported + # pseudo_ctx = Context(mcp=mcp, request=request, params=arguments, tool=tool_instance) + # result = await tool_instance(pseudo_ctx) + + # Method 3: Use mcp.call_tool internal method if accessible and appropriate + # This is speculative based on potential FastMCP internals + if hasattr(mcp, 'call_tool_by_name'): # Hypothetical method + logger.debug("Attempting execution via mcp.call_tool_by_name") + pseudo_ctx_params = arguments # Pass client args + # pseudo_ctx_params['_request'] = request # Maybe pass request? + if callback: pseudo_ctx_params['callback'] = callback # Pass callback? + result = await mcp.call_tool_by_name(tool_name, params=pseudo_ctx_params) + logger.debug(f"Result from mcp.call_tool_by_name: {result}") + else: + # Fallback to manual context simulation if no direct call method exists + logger.debug("Falling back to manual context simulation for tool wrapper execution") + from mcp.server.fastmcp import Context # Ensure imported + # Prepare params for context, including potentially callback + context_params = dict(arguments) + if callback: context_params['callback'] = callback + pseudo_ctx = Context(mcp=mcp, request=request, params=context_params, tool=tool_instance) + result = await tool_instance(pseudo_ctx) # Call the wrapper with simulated context + logger.debug(f"Result from manual context simulation: {result}") + + logger.debug(f"Raw result received in call_tool from registered tool '{tool_name}': {result}") + return result + + except Exception as e: + logger.error(f"Exception during call_tool for '{tool_name}': {str(e)}", exc_info=True) + raise ValueError(f"Error executing tool '{tool_name}': {str(e)}") from e + + + # === Helper Methods (Formatting, Session Cleanup, etc.) === + + def _format_tools(self, tools): + # Helper to format tool list for responses + # Based on mcp/listTools structure + tools_json = [] + for tool in tools: + # Assuming tools from list_tools are the wrapper functions + tool_registered_name = getattr(tool, 'name', getattr(tool, '__name__', None)) + if not tool_registered_name: + logger.warning(f"Could not determine name for tool object: {tool}") + continue + + # Need a way to get description and schema associated with the wrapper + # This might require inspecting the mcp instance's internal storage + mcp = self.app.state.mcp if hasattr(self.app.state, 'mcp') else self.mcp_server + # Hypothetical internal access - THIS IS FRAGILE + tool_spec = mcp.tools.get(tool_registered_name) if hasattr(mcp, 'tools') else None + + description = "" + input_schema = {"type": "object", "properties": {}, "required": []} + if tool_spec and hasattr(tool_spec, 'description'): + description = tool_spec.description + if tool_spec and hasattr(tool_spec, 'parameters'): # Assuming parameters holds the JSON schema + input_schema = tool_spec.parameters + + tools_json.append({ + "name": tool_registered_name, + "description": description, + "inputSchema": input_schema + }) + return tools_json + + def _format_resources(self, resources): + # Helper to format resource list + return [res.model_dump() if hasattr(res, "model_dump") else res for res in resources] + + def _format_prompts(self, prompts): + # Helper to format prompt list + return [prompt.model_dump() if hasattr(prompt, "model_dump") else prompt for prompt in prompts] + + def _format_tool_call_result(self, result: Any) -> Dict[str, Any]: + # Helper to format tool results into MCP Content format + content_list = [] + if isinstance(result, str): + try: + # If it looks like the tool already returned the full JSON RPC like structure + parsed_json = json.loads(result) + if isinstance(parsed_json, dict) and 'content' in parsed_json and isinstance(parsed_json['content'], list): + logger.debug("Tool result already seems formatted with 'content', using as is.") + return parsed_json # Use the structure directly + else: + # Assume it's JSON content, wrap it + content_list.append({"type": "json", "json": parsed_json}) + except json.JSONDecodeError: + # Not JSON, treat as text + content_list.append({"type": "text", "text": result}) + elif isinstance(result, (dict, list)): + # If result is already a dict with a 'content' list, use it directly + if isinstance(result, dict) and 'content' in result and isinstance(result['content'], list): + logger.debug("Tool result dictionary has 'content', using as is.") + return result # Use the structure directly + else: + # Otherwise, assume it's JSON content to be wrapped + content_list.append({"type": "json", "json": result}) + elif result is None: + # Handle None result, maybe return empty content or specific type? + logger.warning("_format_tool_call_result received None result") + content_list.append({"type": "text", "text": ""}) # Example: empty text + else: + # Other types, convert to string and wrap as text + content_list.append({"type": "text", "text": str(result)}) + # Always return a dict with a 'content' key containing a list + return {"content": content_list} + + def _process_tool_arguments(self, tool_name, arguments, recent_query): + # Helper to process tool arguments, including random_string fallback + # Note: Ensure callback is NOT passed here + processed_args = dict(arguments) + processed_args.pop('callback', None) # Explicitly remove callback + + if "random_string" in arguments and tool_name.startswith("mcp_doris_"): + random_string = processed_args.pop("random_string", "") # Remove from processed too + logger.debug(f"Processing random_string '{random_string}' for tool {tool_name}") + + # ... (rest of random_string logic as before) ... + # Example for exec_query: + if tool_name == "mcp_doris_exec_query" and not processed_args.get("sql"): + sql_fallback = random_string or recent_query + # ... (logic to extract SQL from fallback) ... + if sql_extracted: + processed_args["sql"] = sql_extracted + else: + logger.warning(f"Missing sql for {tool_name}, and fallback failed.") + # ... (logic for table_name fallback) ... + + return processed_args + + def _extract_recent_query(self, request: Request) -> Optional[str]: + # Helper to extract recent user query from request + # (Implementation as provided previously) + try: + # Try to extract message history from request body + body = None + body_bytes = getattr(request, "_body", None) + if body_bytes: + try: + body = json.loads(body_bytes) + except: pass + if not body: body = getattr(request, "_json", {}) + + messages = body.get("params", {}).get("messages", []) + if messages: + for msg in reversed(messages): + if msg.get("role") == "user": return msg.get("content", "") + + message = body.get("params", {}).get("message", {}) + if message and message.get("role") == "user": return message.get("content", "") + + return None + except Exception as e: + logger.error(f"Error extracting recent query: {str(e)}") + return None + + async def _cleanup_session_resources(self, session_id: str, session_data: Dict): + # Helper to clean up queues when session is deleted + logger.info(f"Cleaning up resources for session [Session ID: {session_id}]") + # Close general SSE queues + general_queues = session_data.get("general_sse_queues", []) + for queue in general_queues: + try: + await queue.put(STREAM_END_MARKER) + except Exception as e: + logger.warning(f"Error putting end marker in general queue for session {session_id}: {e}") + # Close request-specific SSE queues + request_queues = session_data.get("request_queues", {}) + for req_id, queue in request_queues.items(): + try: + await queue.put(STREAM_END_MARKER) + except Exception as e: + logger.warning(f"Error putting end marker in request queue {req_id} for session {session_id}: {e}") + logger.info(f"Finished cleaning resources for session {session_id}") + + # This method might belong in the main app or a shared utility if needed by both servers + # async def cleanup_idle_sessions(self): + # # ... (implementation - needs access to self.client_sessions) ... + # pass + + # This method might belong in the main app or a shared utility + # async def broadcast_message(self, message): + # # ... (implementation - needs access to self.client_sessions of BOTH servers?) ... + # pass + + # This method is specific to streamable http tool calls + async def send_visualization_data(self, session_id: str, request_id: Any, visualization_data: Any): + """Sends visualization data as a notification on the request stream.""" + if session_id not in self.client_sessions: + logger.warning(f"Cannot send visualization: Session {session_id} not found.") + return + queue = self.client_sessions.get(session_id, {}).get("request_queues", {}).get(request_id) + if not queue: + logger.warning(f"Cannot send visualization: Request queue {request_id} not found for session {session_id}.") + return + + notification = { + "jsonrpc": "2.0", + "method": "tools/visualization", + "params": visualization_data + } + try: + await queue.put(notification) + logger.info(f"Sent visualization notification [Session: {session_id}, Req: {request_id}]") + except Exception as e: + logger.error(f"Error sending visualization notification [Session: {session_id}, Req: {request_id}]: {e}") + + # This might belong in main app or shared utility + # async def send_periodic_updates(self): + # # ... (implementation) ... + # pass + +# End of class DorisMCPStreamableServer \ No newline at end of file diff --git a/doris_mcp_server/tools/__init__.py b/doris_mcp_server/tools/__init__.py new file mode 100644 index 0000000..4b63b39 --- /dev/null +++ b/doris_mcp_server/tools/__init__.py @@ -0,0 +1,23 @@ +from .mcp_doris_tools import ( + mcp_doris_exec_query, + mcp_doris_get_table_schema, + mcp_doris_get_db_table_list, + mcp_doris_get_db_list, + mcp_doris_get_table_comment, + mcp_doris_get_table_column_comments, + mcp_doris_get_table_indexes, + mcp_doris_get_recent_audit_logs +) + +# The __all__ list should reflect the registered tool names, +# even though the implementation functions have the prefix. +__all__ = [ + "exec_query", + "get_table_schema", + "get_db_table_list", + "get_db_list", + "get_table_comment", + "get_table_column_comments", + "get_table_indexes", + "get_recent_audit_logs" +] \ No newline at end of file diff --git a/doris_mcp_server/tools/mcp_doris_tools.py b/doris_mcp_server/tools/mcp_doris_tools.py new file mode 100644 index 0000000..ccaa9f6 --- /dev/null +++ b/doris_mcp_server/tools/mcp_doris_tools.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Doris MCP Tool Implementations + +Includes exec_query and new tools based on schema_extractor. +""" + +import os +import time +import json +import logging +from typing import Dict, Any +import pandas as pd + +# --- Use absolute imports --- +from doris_mcp_server.utils.schema_extractor import MetadataExtractor +from doris_mcp_server.utils.sql_executor_tools import execute_sql_query + +# Get logger +logger = logging.getLogger("doris-mcp-tools") + +# --- Helper Function to format response --- +def _format_response(success: bool, result: Any = None, error: str = None, message: str = "") -> Dict[str, Any]: + response_data = { + "success": success, + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") + } + if success and result is not None: + # Handle DataFrame serialization + if isinstance(result, pd.DataFrame): + try: + # Convert DataFrame to JSON records format + response_data["result"] = json.loads(result.to_json(orient='records', date_format='iso')) + except Exception as df_err: + logger.error(f"DataFrame to JSON conversion failed: {df_err}") + # Fallback or specific error handling for DataFrame + response_data["result"] = {"error": "Failed to serialize DataFrame result"} + response_data["success"] = False # Mark as failed if serialization fails + response_data["error"] = f"DataFrame serialization error: {str(df_err)}" + else: + response_data["result"] = result + response_data["message"] = message or "Operation successful" # Translated: Operation successful + elif not success: + response_data["error"] = error or "Unknown error" # Translated: Unknown error + response_data["message"] = message or "Operation failed" # Translated: Operation failed + + return { + "content": [ + { + "type": "text", + "text": json.dumps(response_data, ensure_ascii=False, default=str) # Use default=str for non-serializable types + } + ] + } + +async def mcp_doris_exec_query(sql: str = None, db_name: str = None, max_rows: int = 100, timeout: int = 30) -> Dict[str, Any]: + """ + Executes an SQL query and returns the result. + + Args: + sql (str): The SQL query to execute. + db_name (str, optional): Target database name. Defaults to the configured default database. + max_rows (int, optional): Maximum number of rows to return. Defaults to 100. + timeout (int, optional): Query timeout in seconds. Defaults to 30. + + Returns: + Dict[str, Any]: A dictionary containing the query result or an error. + """ + logger.info(f"MCP Tool Call: mcp_doris_exec_query, SQL: {sql}, DB: {db_name}, MaxRows: {max_rows}, Timeout: {timeout}") + try: + if not sql: + return _format_response(success=False, error="SQL statement not provided", message="Please provide the SQL statement to execute") + + # Build parameters to pass to execute_sql_query + exec_ctx = { + "params": { + "sql": sql, + "db_name": db_name, + "max_rows": max_rows, + "timeout": timeout + } + } + + # Directly call execute_sql_query to execute the query + exec_result = await execute_sql_query(exec_ctx) + + # The format returned by execute_sql_query is {'content': [{'type': 'text', 'text': json_string}]} + # Need to parse the internal JSON string + if exec_result and 'content' in exec_result and len(exec_result['content']) > 0 and 'text' in exec_result['content'][0]: + try: + # Parse JSON string + result_data = json.loads(exec_result['content'][0]['text']) + + # Directly return the parsed result obtained from execute_sql_query + # This result is already in the format {"success": ..., "data": ..., "columns": ...} or {"success": false, "error": ...} + # _format_response would wrap it again, but here we directly use the parsed data + # Note: This changes the original return structure of this function; it now directly returns the output of sql_executor + # If the _format_response wrapper needs to be maintained, the code below needs adjustment + return { + "content": [ + { + "type": "text", + "text": json.dumps(result_data, ensure_ascii=False, default=str) + } + ] + } + except json.JSONDecodeError as json_err: + logger.error(f"Failed to parse execute_sql_query result: {json_err}") + return _format_response(success=False, error=str(json_err), message="Error parsing SQL execution result") + except Exception as parse_err: + logger.error(f"Unexpected error occurred while processing execute_sql_query result: {parse_err}", exc_info=True) + return _format_response(success=False, error=str(parse_err), message="Unknown error occurred while processing SQL execution result") + else: + logger.error(f"execute_sql_query returned an unexpected format: {exec_result}") + return _format_response(success=False, error="SQL executor returned invalid format", message="Internal error executing SQL query") + + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_exec_query: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error executing SQL query") + + +async def mcp_doris_get_table_schema(table_name: str, db_name: str = None) -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_table_schema, Table: {table_name}, DB: {db_name}") + if not table_name: + return _format_response(success=False, error="Missing table_name parameter") + try: + extractor = MetadataExtractor(db_name=db_name) + schema = extractor.get_table_schema(table_name=table_name, db_name=db_name) + if not schema: + return _format_response(success=False, error="Table not found or has no columns", message=f"Could not get schema for table {db_name or extractor.db_name}.{table_name}") + return _format_response(success=True, result=schema) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_table_schema: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting table schema") + +async def mcp_doris_get_db_table_list(db_name: str = None) -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_db_table_list, DB: {db_name}") + try: + extractor = MetadataExtractor(db_name=db_name) + tables = extractor.get_database_tables(db_name=db_name) + return _format_response(success=True, result=tables) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_db_table_list: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting database table list") + +async def mcp_doris_get_db_list() -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_db_list") + try: + extractor = MetadataExtractor() + databases = extractor.get_all_databases() + return _format_response(success=True, result=databases) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_db_list: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting database list") + +async def mcp_doris_get_table_comment(table_name: str, db_name: str = None) -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_table_comment, Table: {table_name}, DB: {db_name}") + if not table_name: + return _format_response(success=False, error="Missing table_name parameter") + try: + extractor = MetadataExtractor(db_name=db_name) + comment = extractor.get_table_comment(table_name=table_name, db_name=db_name) + return _format_response(success=True, result=comment) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_table_comment: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting table comment") + +async def mcp_doris_get_table_column_comments(table_name: str, db_name: str = None) -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_table_column_comments, Table: {table_name}, DB: {db_name}") + if not table_name: + return _format_response(success=False, error="Missing table_name parameter") + try: + extractor = MetadataExtractor(db_name=db_name) + comments = extractor.get_column_comments(table_name=table_name, db_name=db_name) + return _format_response(success=True, result=comments) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_table_column_comments: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting column comments") + +async def mcp_doris_get_table_indexes(table_name: str, db_name: str = None) -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_table_indexes, Table: {table_name}, DB: {db_name}") + if not table_name: + return _format_response(success=False, error="Missing table_name parameter") + try: + extractor = MetadataExtractor(db_name=db_name) + indexes = extractor.get_table_indexes(table_name=table_name, db_name=db_name) + return _format_response(success=True, result=indexes) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_table_indexes: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting table indexes") + +async def mcp_doris_get_recent_audit_logs(days: int = 7, limit: int = 100) -> Dict[str, Any]: + logger.info(f"MCP Tool Call: mcp_doris_get_recent_audit_logs, Days: {days}, Limit: {limit}") + try: + extractor = MetadataExtractor() + logs_df = extractor.get_recent_audit_logs(days=days, limit=limit) + return _format_response(success=True, result=logs_df) + except Exception as e: + logger.error(f"MCP tool execution failed mcp_doris_get_recent_audit_logs: {str(e)}", exc_info=True) + return _format_response(success=False, error=str(e), message="Error getting audit logs") diff --git a/doris_mcp_server/tools/tool_initializer.py b/doris_mcp_server/tools/tool_initializer.py new file mode 100644 index 0000000..cebef12 --- /dev/null +++ b/doris_mcp_server/tools/tool_initializer.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Tool Initialization Module + +Centralized initialization of all tools, ensuring they are correctly registered with MCP +""" + +import logging +import os +from typing import List, Dict, Any, Optional +import json +from datetime import datetime +import traceback + +# Import Context +from mcp.server.fastmcp import Context + +# Import doris mcp tools +from doris_mcp_server.tools.mcp_doris_tools import ( + mcp_doris_exec_query, + mcp_doris_get_table_schema, + mcp_doris_get_db_table_list, + mcp_doris_get_db_list, + mcp_doris_get_table_comment, + mcp_doris_get_table_column_comments, + mcp_doris_get_table_indexes, + mcp_doris_get_recent_audit_logs +) + +# Get logger +logger = logging.getLogger("doris-mcp-tools-initializer") + +async def register_mcp_tools(mcp): + """Register MCP tool functions + + Args: + mcp: FastMCP instance + """ + logger.info("Starting to register MCP tools...") + + try: + # Register Tool: Execute SQL Query (Using long description string including parameters) + @mcp.tool("exec_query", description="""[Function Description]: Execute SQL query and return result command (executed by the client).\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- sql (string) [Required] - SQL statement to execute\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n +- max_rows (integer) [Optional] - Maximum number of rows to return, default 100 +- timeout (integer) [Optional] - Query timeout in seconds, default 30""") + async def exec_query_tool(sql: str, db_name: str = None, max_rows: int = 100, timeout: int = 30) -> Dict[str, Any]: + """Wrapper: Execute SQL query and return result command""" + # Note: ctx parameter is no longer needed here as we receive named parameters directly + return await mcp_doris_exec_query(sql=sql, db_name=db_name, max_rows=max_rows, timeout=timeout) + + # Register Tool: Get Table Schema (Keep long description string including parameters) + @mcp.tool("get_table_schema", description="""[Function Description]: Get detailed structure information of the specified table (columns, types, comments, etc.).\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") + async def get_table_schema_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table schema""" + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_schema(table_name=table_name, db_name=db_name) + + # Register Tool: Get Database Table List (Keep long description string including parameters) + @mcp.tool("get_db_table_list", description="""[Function Description]: Get a list of all table names in the specified database.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") + async def get_db_table_list_tool(db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get database table list""" + return await mcp_doris_get_db_table_list(db_name=db_name) + + # Register Tool: Get Database List (Keep long description string including parameters) + # Note: Although the description mentions random_string, the wrapper function signature does not. See how mcp handles this. + @mcp.tool("get_db_list", description="""[Function Description]: Get a list of all database names on the server.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n""") + async def get_db_list_tool() -> Dict[str, Any]: # Function signature has no parameters + """Wrapper: Get database list""" + return await mcp_doris_get_db_list() + + # Register Tool: Get Table Comment (Keep long description string including parameters) + @mcp.tool("get_table_comment", description="""[Function Description]: Get the comment information for the specified table.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") + async def get_table_comment_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table comment""" + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_comment(table_name=table_name, db_name=db_name) + + # Register Tool: Get Table Column Comments (Keep long description string including parameters) + @mcp.tool("get_table_column_comments", description="""[Function Description]: Get comment information for all columns in the specified table.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") + async def get_table_column_comments_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table column comments""" + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_column_comments(table_name=table_name, db_name=db_name) + + # Register Tool: Get Table Indexes (Keep long description string including parameters) + @mcp.tool("get_table_indexes", description="""[Function Description]: Get index information for the specified table.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- table_name (string) [Required] - Name of the table to query\n +- db_name (string) [Optional] - Target database name, defaults to the current database\n""") + async def get_table_indexes_tool(table_name: str, db_name: str = None) -> Dict[str, Any]: + """Wrapper: Get table indexes""" + if not table_name: return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "Missing table_name parameter"})}]} + return await mcp_doris_get_table_indexes(table_name=table_name, db_name=db_name) + + # Register Tool: Get Recent Audit Logs (Keep long description string including parameters) + @mcp.tool("get_recent_audit_logs", description="""[Function Description]: Get audit log records for a recent period.\n +[Parameter Content]:\n +- random_string (string) [Required] - Unique identifier for the tool call\n +- days (integer) [Optional] - Number of recent days of logs to retrieve, default is 7\n +- limit (integer) [Optional] - Maximum number of records to return, default is 100\n""") + async def get_recent_audit_logs_tool(days: int = 7, limit: int = 100) -> Dict[str, Any]: + """Wrapper: Get recent audit logs""" + try: + days = int(days) + limit = int(limit) + except (ValueError, TypeError): + return {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "days and limit parameters must be integers"})}]} + return await mcp_doris_get_recent_audit_logs(days=days, limit=limit) + + # Get tool count + tools_count = len(await mcp.list_tools()) + logger.info(f"Registered all MCP tools, total {tools_count} tools") + return True + except Exception as e: + logger.error(f"Error registering MCP tools: {str(e)}") + logger.error(traceback.format_exc()) + return False \ No newline at end of file diff --git a/doris_mcp_server/utils/__init__.py b/doris_mcp_server/utils/__init__.py new file mode 100644 index 0000000..390ee6e --- /dev/null +++ b/doris_mcp_server/utils/__init__.py @@ -0,0 +1 @@ +# Mark directory as a package \ No newline at end of file diff --git a/doris_mcp_server/utils/db.py b/doris_mcp_server/utils/db.py new file mode 100644 index 0000000..4236b39 --- /dev/null +++ b/doris_mcp_server/utils/db.py @@ -0,0 +1,100 @@ +import os +import json +import pymysql +import pandas as pd +from typing import Dict, List, Optional, Any +from dotenv import load_dotenv +import re + +# Load environment variables +load_dotenv(override=True) + +# Database configuration +DB_CONFIG = { + "host": os.getenv("DB_HOST", "localhost"), + "port": int(os.getenv("DB_PORT", "9030")), + "user": os.getenv("DB_USER", "root"), + "password": os.getenv("DB_PASSWORD", ""), + "database": os.getenv("DB_DATABASE", ""), + "charset": "utf8mb4", + "cursorclass": pymysql.cursors.DictCursor +} + +def get_db_connection(db_name: Optional[str] = None): + """ + Get database connection + + Args: + db_name: Specify the database name to connect to, use default config if None + + Returns: + Database connection + """ + if db_name: + # Use default config but override database name + config = DB_CONFIG.copy() + config["database"] = db_name + return pymysql.connect(**config) + else: + # Use default config + return pymysql.connect(**DB_CONFIG) + +def get_db_name() -> str: + """Get the currently configured default database name""" + return DB_CONFIG["database"] or os.getenv("DB_DATABASE", "") + +def execute_query(sql, db_name: Optional[str] = None): + """ + Execute SQL query and return results + + Args: + sql: SQL query statement + db_name: Specify the database name to connect to, use default config if None + + Returns: + Query results + """ + conn = get_db_connection(db_name) + try: + with conn.cursor() as cursor: + # Set connection character set to utf8 before executing query + cursor.execute("SET NAMES utf8") + + # Execute the actual query + cursor.execute(sql) + result = cursor.fetchall() + return result + finally: + conn.close() + +def execute_query_df(sql, db_name: Optional[str] = None): + """ + Execute SQL query and return pandas DataFrame + + Args: + sql: SQL query statement + db_name: Specify the database name to connect to, use default config if None + + Returns: + pandas DataFrame + """ + conn = get_db_connection(db_name) + try: + # Use a temporary cursor to execute the query and get results + with conn.cursor() as cursor: + # Set connection character set to utf8 before executing query + cursor.execute("SET NAMES utf8") + + # Execute the actual query + cursor.execute(sql) + result = cursor.fetchall() + + # If no results, return empty DataFrame + if not result: + return pd.DataFrame() + + # Manually convert dict results to DataFrame + df = pd.DataFrame(result) + return df + finally: + conn.close() \ No newline at end of file diff --git a/doris_mcp_server/utils/logger.py b/doris_mcp_server/utils/logger.py new file mode 100644 index 0000000..7baa53d --- /dev/null +++ b/doris_mcp_server/utils/logger.py @@ -0,0 +1,226 @@ +""" +Unified Logging Configuration Module + +Provides unified logging configuration, including: +- General logs: Record all program execution information +- Audit logs: Record JSON data for key operations and processing results +- Error logs: Specifically record program exceptions and errors +""" + +import os +import sys +import logging +import logging.handlers +from pathlib import Path +from typing import Dict +from datetime import datetime +from dotenv import load_dotenv + +# Load environment variables +load_dotenv(override=True) + +# Get project root directory +PROJECT_ROOT = Path(__file__).parents[2].absolute() + +# Get log configuration from environment variables +LOG_DIR = os.getenv("LOG_DIR", str(PROJECT_ROOT / "logs")) +LOG_PREFIX = os.getenv("LOG_PREFIX", "doris_mcp") +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() +LOG_MAX_DAYS = int(os.getenv("LOG_MAX_DAYS", "30")) +# Whether to output logs to the console (should be disabled when running as a service) +CONSOLE_LOGGING = os.getenv("CONSOLE_LOGGING", "false").lower() == "true" +# Whether stdio transport mode is being used +STDIO_MODE = os.getenv("MCP_TRANSPORT_TYPE", "").lower() == "stdio" + +def purge_old_logs(): + """Clean up expired log files""" + # --- Only perform cleanup in non-Stdio mode --- + if STDIO_MODE: + return + try: + now = datetime.now() + log_dir = Path(LOG_DIR) + # Check if directory exists and is readable/writable + if not log_dir.is_dir() or not os.access(LOG_DIR, os.W_OK): + if not STDIO_MODE: # Avoid printing to stdout in stdio mode + print(f"Warning: Log directory {LOG_DIR} not accessible, skipping log purge.", file=sys.stderr) + return + + for log_file in log_dir.glob(f"{LOG_PREFIX}*.20*"): + # Parse date + file_name = log_file.name + date_str = None + + # Try to find the date part + parts = file_name.split('.') + for part in parts: + if part.startswith('20') and len(part) == 8: # 20YYMMDD format + date_str = part + break + + if date_str: + try: + file_date = datetime.strptime(date_str, '%Y%m%d') + days_old = (now - file_date).days + + if days_old > LOG_MAX_DAYS: + os.remove(log_file) + if not STDIO_MODE: + print(f"Deleted expired log file: {log_file}") + except (ValueError, OSError) as e: + if not STDIO_MODE: + print(f"Error processing log file {file_name}: {e}", file=sys.stderr) + except Exception as e: + if not STDIO_MODE: + print(f"Error cleaning up logs: {e}", file=sys.stderr) + +# Force disable console log output if in stdio mode +if STDIO_MODE: + CONSOLE_LOGGING = False + +# --- Only create log directory and clean old logs in non-Stdio mode --- +if not STDIO_MODE: + try: + os.makedirs(LOG_DIR, exist_ok=True) + # Clean up expired logs on startup (also moved here, as it only handles file logs) + purge_old_logs() + except OSError as e: + # If directory creation fails (e.g., permission issue), print warning but continue to avoid startup failure + print(f"Warning: Failed to create log directory {LOG_DIR} or purge logs: {e}", file=sys.stderr) + +# Log file paths (definition still needed, but files might not be created/used) +LOG_FILE = os.path.join(LOG_DIR, f"{LOG_PREFIX}.log") +AUDIT_LOG_FILE = os.path.join(LOG_DIR, f"{LOG_PREFIX}.audit") +ERROR_LOG_FILE = os.path.join(LOG_DIR, f"{LOG_PREFIX}.error") + +# Log level mapping +LOG_LEVELS = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL +} + +# Log format +LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +AUDIT_FORMAT = '%(asctime)s - %(name)s - %(message)s' +ERROR_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(pathname)s:%(lineno)d - %(message)s' + +# Dedicated audit log level +AUDIT = 25 # Level between INFO and WARNING +logging.addLevelName(AUDIT, "AUDIT") + +# Logger object cache +_loggers: Dict[str, logging.Logger] = {} + +# Handler type mapping, used to ensure no duplicates are added +_handler_types = { + 'console': logging.StreamHandler, + 'file': logging.handlers.TimedRotatingFileHandler, + 'audit': logging.handlers.TimedRotatingFileHandler, + 'error': logging.handlers.TimedRotatingFileHandler +} + + +def get_logger(name: str) -> logging.Logger: + """ + Get a logger with the specified name + + Args: + name: Logger name + + Returns: + logging.Logger: Configured logger + """ + if name in _loggers: + return _loggers[name] + + # Create logger + logger = logging.getLogger(name) + logger.setLevel(LOG_LEVELS.get(LOG_LEVEL, logging.INFO)) + + # Avoid duplicate logs caused by propagation + logger.propagate = False + + # Check if handlers already exist to avoid duplicates + handler_types = set(type(h) for h in logger.handlers) + + # Add audit log method + def audit(self, message, *args, **kwargs): + self.log(AUDIT, message, *args, **kwargs) + + logger.audit = audit.__get__(logger) + + # General log handler - output to console (only if enabled) + if CONSOLE_LOGGING and _handler_types['console'] not in handler_types: + # Use stderr instead of stdout to avoid conflicts with MCP communication + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter(logging.Formatter(LOG_FORMAT)) + logger.addHandler(console_handler) + + # --- Only add file handlers in non-Stdio mode --- + if not STDIO_MODE: + # General log handler - daily rotating file + if _handler_types['file'] not in handler_types: + try: # Add try-except block + file_handler = logging.handlers.TimedRotatingFileHandler( + LOG_FILE, + when='midnight', + interval=1, + backupCount=LOG_MAX_DAYS, + encoding='utf-8' + ) + file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) + file_handler.suffix = "%Y%m%d" + logger.addHandler(file_handler) + except OSError as e: + print(f"Warning: Failed to add file log handler for {LOG_FILE}: {e}", file=sys.stderr) + + # Audit log handler - only logs AUDIT level + if _handler_types['audit'] not in handler_types: + try: # Add try-except block + audit_handler = logging.handlers.TimedRotatingFileHandler( + AUDIT_LOG_FILE, + when='midnight', + interval=1, + backupCount=LOG_MAX_DAYS, + encoding='utf-8' + ) + audit_handler.setFormatter(logging.Formatter(AUDIT_FORMAT)) + audit_handler.suffix = "%Y%m%d" + audit_handler.setLevel(AUDIT) + audit_handler.addFilter(lambda record: record.levelno == AUDIT) + logger.addHandler(audit_handler) + except OSError as e: + print(f"Warning: Failed to add audit log handler for {AUDIT_LOG_FILE}: {e}", file=sys.stderr) + + # Error log handler - only logs ERROR level and above + if _handler_types['error'] not in handler_types: + try: # Add try-except block + error_handler = logging.handlers.TimedRotatingFileHandler( + ERROR_LOG_FILE, + when='midnight', + interval=1, + backupCount=LOG_MAX_DAYS, + encoding='utf-8' + ) + error_handler.setFormatter(logging.Formatter(ERROR_FORMAT)) + error_handler.suffix = "%Y%m%d" + error_handler.setLevel(logging.ERROR) + logger.addHandler(error_handler) + except OSError as e: + print(f"Warning: Failed to add error log handler for {ERROR_LOG_FILE}: {e}", file=sys.stderr) + + # Cache logger + _loggers[name] = logger + + return logger + +# Default logger +logger = get_logger('doris_mcp') + +# Audit logger - for recording processing results, business operations, etc. +audit_logger = get_logger('audit') + +# Call to clean logs moved after directory creation, and added non-stdio check \ No newline at end of file diff --git a/doris_mcp_server/utils/schema_extractor.py b/doris_mcp_server/utils/schema_extractor.py new file mode 100644 index 0000000..ec90a28 --- /dev/null +++ b/doris_mcp_server/utils/schema_extractor.py @@ -0,0 +1,1013 @@ +""" +Metadata Extraction Tool + +Responsible for extracting table structures, relationships, and other metadata from the database. +""" + +import os +import json +import pandas as pd +import re +from typing import Dict, List, Any, Optional, Tuple +from dotenv import load_dotenv +from datetime import datetime, timedelta + +# Import unified logging configuration +from doris_mcp_server.utils.logger import get_logger + +# Configure logging +logger = get_logger(__name__) + +# Load environment variables +load_dotenv(override=True) + +METADATA_DB_NAME="information_schema" +ENABLE_MULTI_DATABASE=os.getenv("ENABLE_MULTI_DATABASE",True) +MULTI_DATABASE_NAMES=os.getenv("MULTI_DATABASE_NAMES","") + +# Import local modules +from doris_mcp_server.utils.db import execute_query_df, execute_query + +class MetadataExtractor: + """Apache Doris Metadata Extractor""" + + def __init__(self, db_name: str = None): + """ + Initialize the metadata extractor + + Args: + db_name: Default database name, uses the currently connected database if not specified + """ + # Get configuration from environment variables + self.db_name = db_name or os.getenv("DB_DATABASE", "") + self.metadata_db = METADATA_DB_NAME # Use constant + + # Caching system + self.metadata_cache = {} + self.metadata_cache_time = {} + self.cache_ttl = int(os.getenv("METADATA_CACHE_TTL", "3600")) # Default cache 1 hour + + # Refresh time + self.last_refresh_time = None + + # Enable multi-database support - use variable imported from db.py + self.enable_multi_database = ENABLE_MULTI_DATABASE + + # Load table hierarchy matching configuration + self.enable_table_hierarchy = os.getenv("ENABLE_TABLE_HIERARCHY", "false").lower() == "true" + if self.enable_table_hierarchy: + self.table_hierarchy_patterns = self._load_table_hierarchy_patterns() + else: + self.table_hierarchy_patterns = [] + + # List of excluded system databases + self.excluded_databases = self._load_excluded_databases() + + def _load_excluded_databases(self) -> List[str]: + """ + Load the list of excluded databases configuration + + Returns: + List of excluded databases + """ + excluded_dbs_str = os.getenv("EXCLUDED_DATABASES", + '["information_schema", "mysql", "performance_schema", "sys", "doris_metadata"]') + try: + excluded_dbs = json.loads(excluded_dbs_str) + if isinstance(excluded_dbs, list): + logger.info(f"Loaded excluded database list: {excluded_dbs}") + return excluded_dbs + else: + logger.warning("Excluded database list configuration is not in list format, using default value") + except json.JSONDecodeError: + logger.warning("Error parsing excluded database list JSON, using default value") + + # Default value + default_excluded_dbs = ["information_schema", "mysql", "performance_schema", "sys", "doris_metadata"] + return default_excluded_dbs + + def _load_table_hierarchy_patterns(self) -> List[str]: + """ + Load table hierarchy matching pattern configuration + + Returns: + List of table hierarchy matching regular expressions + """ + patterns_str = os.getenv("TABLE_HIERARCHY_PATTERNS", + '["^ads_.*$","^dim_.*$","^dws_.*$","^dwd_.*$","^ods_.*$","^tmp_.*$","^stg_.*$","^.*$"]') + try: + patterns = json.loads(patterns_str) + if isinstance(patterns, list): + # Ensure all patterns are valid regular expressions + validated_patterns = [] + for pattern in patterns: + try: + re.compile(pattern) + validated_patterns.append(pattern) + except re.error: + logger.warning(f"Invalid regular expression pattern: {pattern}") + + logger.info(f"Loaded table hierarchy matching patterns: {validated_patterns}") + return validated_patterns + else: + logger.warning("Table hierarchy matching pattern configuration is not in list format, using default value") + except json.JSONDecodeError: + logger.warning("Error parsing table hierarchy matching pattern JSON, using default value") + + # Default value + default_patterns = ["^ads_.*$", "^dim_.*$", "^dws_.*$", "^dwd_.*$", "^ods_.*$", "^.*$"] + return default_patterns + + def get_all_databases(self) -> List[str]: + """ + Get a list of all databases + + Returns: + List of database names + """ + cache_key = "databases" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + # Use information_schema.schemata table to get database list + query = """ + SELECT + SCHEMA_NAME + FROM + information_schema.schemata + WHERE + SCHEMA_NAME NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') + ORDER BY + SCHEMA_NAME + """ + + result = execute_query(query) + + if not result: + databases = [] + else: + databases = [db["SCHEMA_NAME"] for db in result] + logger.info(f"Retrieved database list: {databases}") + + # Update cache + self.metadata_cache[cache_key] = databases + self.metadata_cache_time[cache_key] = datetime.now() + + return databases + except Exception as e: + logger.error(f"Error getting database list: {str(e)}") + return [] + + def get_all_target_databases(self) -> List[str]: + """ + Get all target databases + + If multi-database support is enabled, returns all databases from the configuration; + Otherwise, returns the current database + + Returns: + List of target databases + """ + if self.enable_multi_database: + # Get multi-database list from configuration + from doris_mcp_server.utils.db import MULTI_DATABASE_NAMES + + # If configuration is empty, return current database and all databases in the system + if not MULTI_DATABASE_NAMES: + all_dbs = self.get_all_databases() + # Put the current database at the front + if self.db_name in all_dbs: + all_dbs.remove(self.db_name) + all_dbs = [self.db_name] + all_dbs + + # Filter out excluded databases + all_dbs = [db for db in all_dbs if db not in self.excluded_databases] + logger.info(f"Multi-database list not configured, getting database list from system: {all_dbs}") + return all_dbs + else: + # Ensure the current database is in the list and at the front + db_names = list(MULTI_DATABASE_NAMES) # Copy to avoid modifying the original list + if self.db_name and self.db_name not in db_names: + db_names.insert(0, self.db_name) + elif self.db_name and self.db_name in db_names: + # If current database is in the list but not first, adjust position + db_names.remove(self.db_name) + db_names.insert(0, self.db_name) + + # Filter out excluded databases + db_names = [db for db in db_names if db not in self.excluded_databases] + logger.info(f"Using configured multi-database list: {db_names}") + return db_names + else: + # Return only the current database + if self.db_name in self.excluded_databases: + logger.warning(f"Current database {self.db_name} is in the excluded list, metadata retrieval might not work properly") + return [self.db_name] if self.db_name else [] + + def get_database_tables(self, db_name: Optional[str] = None) -> List[str]: + """ + Get a list of all tables in the database + + Args: + db_name: Database name, uses current database if None + + Returns: + List of table names + """ + db_name = db_name or self.db_name + if not db_name: + logger.warning("Database name not specified") + return [] + + cache_key = f"tables_{db_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + # Use information_schema.tables table to get table list + query = f""" + SELECT + TABLE_NAME + FROM + information_schema.tables + WHERE + TABLE_SCHEMA = '{db_name}' + AND TABLE_TYPE = 'BASE TABLE' + """ + + result = execute_query(query, db_name) + logger.info(f"{db_name}.information_schema.tables query result: {result}") + + if not result: + tables = [] + else: + tables = [table['TABLE_NAME'] for table in result] + logger.info(f"Table names retrieved from {db_name}.information_schema.tables: {tables}") + + # Sort tables by hierarchy matching (if enabled) + if self.enable_table_hierarchy and tables: + tables = self._sort_tables_by_hierarchy(tables) + + # Update cache + self.metadata_cache[cache_key] = tables + self.metadata_cache_time[cache_key] = datetime.now() + + return tables + except Exception as e: + logger.error(f"Error getting table list: {str(e)}") + return [] + + def get_all_tables_and_columns(self) -> Dict[str, Any]: + """ + Get information for all tables and columns + + Returns: + Dict[str, Any]: Dictionary containing information for all tables and columns + """ + cache_key = f"all_tables_columns_{self.db_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + result = {} + tables = self.get_database_tables(self.db_name) + + for table_name in tables: + schema = self.get_table_schema(table_name, self.db_name) + if schema: + columns = schema.get("columns", []) + column_names = [col.get("name") for col in columns if col.get("name")] + column_types = {col.get("name"): col.get("type") for col in columns if col.get("name") and col.get("type")} + column_comments = {col.get("name"): col.get("comment") for col in columns if col.get("name")} + + result[table_name] = { + "comment": schema.get("comment", ""), + "columns": column_names, + "column_types": column_types, + "column_comments": column_comments + } + + # Update cache + self.metadata_cache[cache_key] = result + self.metadata_cache_time[cache_key] = datetime.now() + + return result + except Exception as e: + logger.error(f"Error getting all tables and columns information: {str(e)}") + return {} + + def _sort_tables_by_hierarchy(self, tables: List[str]) -> List[str]: + """ + Sort tables based on hierarchy matching patterns + + Args: + tables: List of table names + + Returns: + Sorted list of table names + """ + if not self.enable_table_hierarchy or not self.table_hierarchy_patterns: + return tables + + # Group tables by pattern priority + table_groups = [] + remaining_tables = set(tables) + + for pattern in self.table_hierarchy_patterns: + matching_tables = [] + regex = re.compile(pattern) + + for table in list(remaining_tables): + if regex.match(table): + matching_tables.append(table) + remaining_tables.remove(table) + + if matching_tables: + # Within each group, sort alphabetically + matching_tables.sort() + table_groups.append(matching_tables) + + # Add remaining tables to the end + if remaining_tables: + table_groups.append(sorted(list(remaining_tables))) + + # Flatten the groups + return [table for group in table_groups for table in group] + + def get_all_tables_from_all_databases(self) -> Dict[str, List[str]]: + """ + Get all tables from all target databases + + Returns: + Mapping from database name to list of table names + """ + all_tables = {} + target_dbs = self.get_all_target_databases() + + for db_name in target_dbs: + tables = self.get_database_tables(db_name) + if tables: + all_tables[db_name] = tables + + return all_tables + + def find_tables_by_pattern(self, pattern: str, db_name: Optional[str] = None) -> List[Tuple[str, str]]: + """ + Find matching tables in the database based on a pattern + + Args: + pattern: Table name pattern (regular expression) + db_name: Database name, searches all target databases if None + + Returns: + List of matching (database_name, table_name) tuples + """ + try: + regex = re.compile(pattern) + except re.error: + logger.error(f"Invalid regular expression pattern: {pattern}") + return [] + + matches = [] + + if db_name: + # Search only in the specified database + tables = self.get_database_tables(db_name) + matches = [(db_name, table) for table in tables if regex.match(table)] + else: + # Search in all target databases + all_tables = self.get_all_tables_from_all_databases() + + for db, tables in all_tables.items(): + db_matches = [(db, table) for table in tables if regex.match(table)] + matches.extend(db_matches) + + return matches + + def get_table_schema(self, table_name: str, db_name: Optional[str] = None) -> Dict[str, Any]: + """ + Get the schema information for a table + + Args: + table_name: Table name + db_name: Database name, uses current database if None + + Returns: + Table schema information, including column names, types, nullability, defaults, comments, etc. + """ + db_name = db_name or self.db_name + if not db_name: + logger.warning("Database name not specified") + return {} + + cache_key = f"schema_{db_name}_{table_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + # Use information_schema.columns table to get table schema + query = f""" + SELECT + COLUMN_NAME, + DATA_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + COLUMN_COMMENT, + ORDINAL_POSITION, + COLUMN_KEY, + EXTRA + FROM + information_schema.columns + WHERE + TABLE_SCHEMA = '{db_name}' + AND TABLE_NAME = '{table_name}' + ORDER BY + ORDINAL_POSITION + """ + + result = execute_query(query) + + if not result: + logger.warning(f"Table {db_name}.{table_name} does not exist or has no columns") + return {} + + # Create structured table schema information + columns = [] + for col in result: + # Ensure using actual column values, not column names + column_info = { + "name": col.get("COLUMN_NAME", ""), + "type": col.get("DATA_TYPE", ""), + "nullable": col.get("IS_NULLABLE", "") == "YES", + "default": col.get("COLUMN_DEFAULT", ""), + "comment": col.get("COLUMN_COMMENT", "") or "", + "position": col.get("ORDINAL_POSITION", ""), + "key": col.get("COLUMN_KEY", "") or "", + "extra": col.get("EXTRA", "") or "" + } + columns.append(column_info) + + # Get table comment + table_comment = self.get_table_comment(table_name, db_name) + + # Build complete structure + schema = { + "name": table_name, + "database": db_name, + "comment": table_comment, + "columns": columns, + "create_time": datetime.now().isoformat() + } + + # Get table type information + try: + table_type_query = f""" + SELECT + TABLE_TYPE, + ENGINE + FROM + information_schema.tables + WHERE + TABLE_SCHEMA = '{db_name}' + AND TABLE_NAME = '{table_name}' + """ + table_type_result = execute_query(table_type_query) + if table_type_result: + schema["table_type"] = table_type_result[0].get("TABLE_TYPE", "") + schema["engine"] = table_type_result[0].get("ENGINE", "") + except Exception as e: + logger.warning(f"Error getting table type information: {str(e)}") + + # Update cache + self.metadata_cache[cache_key] = schema + self.metadata_cache_time[cache_key] = datetime.now() + + return schema + except Exception as e: + logger.error(f"Error getting table schema: {str(e)}") + return {} + + def get_table_comment(self, table_name: str, db_name: Optional[str] = None) -> str: + """ + Get the comment for a table + + Args: + table_name: Table name + db_name: Database name, uses current database if None + + Returns: + Table comment + """ + db_name = db_name or self.db_name + if not db_name: + logger.warning("Database name not specified") + return "" + + cache_key = f"table_comment_{db_name}_{table_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + # Use information_schema.tables table to get table comment + query = f""" + SELECT + TABLE_COMMENT + FROM + information_schema.tables + WHERE + TABLE_SCHEMA = '{db_name}' + AND TABLE_NAME = '{table_name}' + """ + + result = execute_query(query) + + if not result or not result[0]: + comment = "" + else: + comment = result[0].get("TABLE_COMMENT", "") + + # Update cache + self.metadata_cache[cache_key] = comment + self.metadata_cache_time[cache_key] = datetime.now() + + return comment + except Exception as e: + logger.error(f"Error getting table comment: {str(e)}") + return "" + + def get_column_comments(self, table_name: str, db_name: Optional[str] = None) -> Dict[str, str]: + """ + Get comments for all columns in a table + + Args: + table_name: Table name + db_name: Database name, uses current database if None + + Returns: + Dictionary of column names and comments + """ + db_name = db_name or self.db_name + if not db_name: + logger.warning("Database name not specified") + return {} + + cache_key = f"column_comments_{db_name}_{table_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + # Use information_schema.columns table to get column comments + query = f""" + SELECT + COLUMN_NAME, + COLUMN_COMMENT + FROM + information_schema.columns + WHERE + TABLE_SCHEMA = '{db_name}' + AND TABLE_NAME = '{table_name}' + ORDER BY + ORDINAL_POSITION + """ + + result = execute_query(query) + + comments = {} + for col in result: + column_name = col.get("COLUMN_NAME", "") + column_comment = col.get("COLUMN_COMMENT", "") + if column_name: + comments[column_name] = column_comment + + # Update cache + self.metadata_cache[cache_key] = comments + self.metadata_cache_time[cache_key] = datetime.now() + + return comments + except Exception as e: + logger.error(f"Error getting column comments: {str(e)}") + return {} + + def get_table_indexes(self, table_name: str, db_name: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Get the index information for a table + + Args: + table_name: Table name + db_name: Database name, uses the database specified during initialization if None + + Returns: + List[Dict[str, Any]]: List of index information + """ + db_name = db_name or self.db_name + if not db_name: + logger.error("Database name not specified") + return [] + + cache_key = f"indexes_{db_name}_{table_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + query = f"SHOW INDEX FROM `{db_name}`.`{table_name}`" + df = execute_query_df(query) + + # Process results + indexes = [] + current_index = None + + for _, row in df.iterrows(): + index_name = row['Key_name'] + column_name = row['Column_name'] + + if current_index is None or current_index['name'] != index_name: + if current_index is not None: + indexes.append(current_index) + + current_index = { + 'name': index_name, + 'columns': [column_name], + 'unique': row['Non_unique'] == 0, + 'type': row['Index_type'] + } + else: + current_index['columns'].append(column_name) + + if current_index is not None: + indexes.append(current_index) + + # Update cache + self.metadata_cache[cache_key] = indexes + self.metadata_cache_time[cache_key] = datetime.now() + + return indexes + except Exception as e: + logger.error(f"Error getting index information: {str(e)}") + return [] + + def get_table_relationships(self) -> List[Dict[str, Any]]: + """ + Infer table relationships from table comments and naming patterns + + Returns: + List[Dict[str, Any]]: List of table relationship information + """ + cache_key = f"relationships_{self.db_name}" + if cache_key in self.metadata_cache and (datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min)).total_seconds() < self.cache_ttl: + return self.metadata_cache[cache_key] + + try: + # Get all tables + tables = self.get_database_tables(self.db_name) + relationships = [] + + # Simple foreign key naming convention detection + # Example: If a table has a column named xxx_id and another table named xxx exists, it might be a foreign key relationship + for table_name in tables: + schema = self.get_table_schema(table_name, self.db_name) + columns = schema.get("columns", []) + + for column in columns: + column_name = column["name"] + if column_name.endswith('_id'): + # Possible foreign key table name + ref_table_name = column_name[:-3] # Remove _id suffix + + # Check if the possible table exists + if ref_table_name in tables: + # Find possible primary key column + ref_schema = self.get_table_schema(ref_table_name, self.db_name) + ref_columns = ref_schema.get("columns", []) + + # Assume primary key column name is id + if any(col["name"] == "id" for col in ref_columns): + relationships.append({ + "table": table_name, + "column": column_name, + "references_table": ref_table_name, + "references_column": "id", + "relationship_type": "many-to-one", + "confidence": "medium" # Low confidence, based on naming convention + }) + + # Update cache + self.metadata_cache[cache_key] = relationships + self.metadata_cache_time[cache_key] = datetime.now() + + return relationships + except Exception as e: + logger.error(f"Error inferring table relationships: {str(e)}") + return [] + + def get_recent_audit_logs(self, days: int = 7, limit: int = 100) -> pd.DataFrame: + """ + Get recent audit logs + + Args: + days: Get audit logs for the last N days + limit: Maximum number of records to return + + Returns: + pd.DataFrame: Audit log DataFrame + """ + try: + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + query = f""" + SELECT client_ip, user, db, time, stmt_id, stmt, state, error_code + FROM `__internal_schema`.`audit_log` + WHERE `time` >= '{start_date}' + AND state = 'EOF' AND error_code = 0 + AND `stmt` NOT LIKE 'SHOW%' + AND `stmt` NOT LIKE 'DESC%' + AND `stmt` NOT LIKE 'EXPLAIN%' + AND `stmt` NOT LIKE 'SELECT 1%' + ORDER BY time DESC + LIMIT {limit} + """ + df = execute_query_df(query) + return df + except Exception as e: + logger.error(f"Error getting audit logs: {str(e)}") + return pd.DataFrame() + + def extract_sql_comments(self, sql: str) -> str: + """ + Extract comments from SQL + + Args: + sql: SQL query + + Returns: + str: Extracted comments + """ + # Extract single-line comments + single_line_comments = re.findall(r'--\s*(.*?)(?:\n|$)', sql) + + # Extract multi-line comments + multi_line_comments = re.findall(r'/\*(.*?)\*/', sql, re.DOTALL) + + # Merge all comments + all_comments = single_line_comments + multi_line_comments + return '\n'.join(comment.strip() for comment in all_comments if comment.strip()) + + def extract_common_sql_patterns(self, limit: int = 50) -> List[Dict[str, Any]]: + """ + Extract common SQL patterns + + Args: + limit: Maximum number of audit logs to retrieve + + Returns: + List[Dict[str, Any]]: List of SQL pattern information, including pattern, type, frequency, etc. + """ + try: + # Get audit logs + audit_logs = self.get_recent_audit_logs(days=30, limit=limit) + if audit_logs.empty: + # If audit logs cannot be retrieved, return some default patterns + default_patterns = [ + { + "pattern": "SELECT * FROM {table} WHERE {condition}", + "type": "SELECT", + "frequency": 1 + }, + { + "pattern": "SELECT {columns} FROM {table} GROUP BY {group_by} ORDER BY {order_by} LIMIT {limit}", + "type": "SELECT", + "frequency": 1 + } + ] + return default_patterns + + # Group and process by SQL type + patterns_by_type = {} + for _, row in audit_logs.iterrows(): + sql = row['stmt'] + if not sql: + continue + + # Determine SQL type + sql_type = self._get_sql_type(sql) + if not sql_type: + continue + + # Simplify SQL + simplified_sql = self._simplify_sql(sql) + + # Extract involved tables + tables = self._extract_tables_from_sql(sql) + + # Extract SQL comments + comments = self.extract_sql_comments(sql) + + # Initialize if it's a new pattern + if sql_type not in patterns_by_type: + patterns_by_type[sql_type] = [] + + # Check if a similar pattern exists + found_similar = False + for pattern in patterns_by_type[sql_type]: + if self._are_sqls_similar(simplified_sql, pattern['simplified_sql']): + pattern['count'] += 1 + pattern['examples'].append(sql) + if comments: + pattern['comments'].append(comments) + found_similar = True + break + + # If no similar pattern found, add new pattern + if not found_similar: + patterns_by_type[sql_type].append({ + 'simplified_sql': simplified_sql, + 'examples': [sql], + 'comments': [comments] if comments else [], + 'count': 1, + 'tables': tables + }) + + # Convert grouped patterns to the required output format + result_patterns = [] + + # Sort by frequency and convert format + for sql_type, type_patterns in patterns_by_type.items(): + sorted_patterns = sorted(type_patterns, key=lambda x: x['count'], reverse=True) + + # Extract top 3 patterns and convert to expected format + for pattern in sorted_patterns[:3]: + # Create output consistent with the format used in _update_sql_patterns_for_all_databases + result_patterns.append({ + "pattern": pattern['simplified_sql'], + "type": sql_type, + "frequency": pattern['count'], + "examples": json.dumps(pattern['examples'][:3], ensure_ascii=False), + "comments": json.dumps(pattern['comments'][:3], ensure_ascii=False) if pattern['comments'] else "[]", + "tables": json.dumps(pattern['tables'], ensure_ascii=False) + }) + + # If no patterns found, return default values + if not result_patterns: + default_patterns = [ + { + "pattern": "SELECT * FROM {table} WHERE {condition}", + "type": "SELECT", + "frequency": 1, + "examples": "[]", + "comments": "[]", + "tables": "[]" + }, + { + "pattern": "SELECT {columns} FROM {table} GROUP BY {group_by} ORDER BY {order_by} LIMIT {limit}", + "type": "SELECT", + "frequency": 1, + "examples": "[]", + "comments": "[]", + "tables": "[]" + } + ] + return default_patterns + + return result_patterns + + except Exception as e: + logger.error(f"Error extracting SQL patterns: {str(e)}") + # Return some default patterns to ensure subsequent processing doesn't fail + default_patterns = [ + { + "pattern": "SELECT * FROM {table} WHERE {condition}", + "type": "SELECT", + "frequency": 1, + "examples": "[]", + "comments": "[]", + "tables": "[]" + }, + { + "pattern": "SELECT {columns} FROM {table} GROUP BY {group_by} ORDER BY {order_by} LIMIT {limit}", + "type": "SELECT", + "frequency": 1, + "examples": "[]", + "comments": "[]", + "tables": "[]" + } + ] + return default_patterns + + def _simplify_sql(self, sql: str) -> str: + """ + Simplify SQL for better pattern recognition + + Args: + sql: SQL query + + Returns: + str: Simplified SQL + """ + # Remove comments + sql = re.sub(r'--.*?(\n|$)', ' ', sql) + sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.DOTALL) + + # Replace string and numeric constants + sql = re.sub(r"'[^']*'", "'?'", sql) + sql = re.sub(r'\b\d+\b', '?', sql) + + # Replace contents of IN clauses + sql = re.sub(r'IN\s*\([^)]+\)', 'IN (?)', sql, flags=re.IGNORECASE) + + # Remove excess whitespace + sql = re.sub(r'\s+', ' ', sql).strip() + + return sql + + + def _extract_tables_from_sql(self, sql: str) -> List[str]: + """ + Extract table names from SQL + + Args: + sql: SQL query + + Returns: + List[str]: List of table names + """ + # This is a very simplified implementation + # Real applications require more complex SQL parsing + tables = set() + + # Find table names after FROM clause + from_matches = re.finditer(r'\bFROM\s+`?(\w+)`?', sql, re.IGNORECASE) + for match in from_matches: + tables.add(match.group(1)) + + # Find table names after JOIN clause + join_matches = re.finditer(r'\bJOIN\s+`?(\w+)`?', sql, re.IGNORECASE) + for match in join_matches: + tables.add(match.group(1)) + + # Find table names after INSERT INTO + insert_matches = re.finditer(r'\bINSERT\s+INTO\s+`?(\w+)`?', sql, re.IGNORECASE) + for match in insert_matches: + tables.add(match.group(1)) + + # Find table names after UPDATE + update_matches = re.finditer(r'\bUPDATE\s+`?(\w+)`?', sql, re.IGNORECASE) + for match in update_matches: + tables.add(match.group(1)) + + # Find table names after DELETE FROM + delete_matches = re.finditer(r'\bDELETE\s+FROM\s+`?(\w+)`?', sql, re.IGNORECASE) + for match in delete_matches: + tables.add(match.group(1)) + + return list(tables) + + + + def get_table_partition_info(self, db_name: str, table_name: str) -> Dict[str, Any]: + """ + Get partition information for a table + + Args: + db_name: Database name + table_name: Table name + + Returns: + Dict: Partition information + """ + try: + # Get partition information + query = f""" + SELECT + PARTITION_NAME, + PARTITION_EXPRESSION, + PARTITION_DESCRIPTION, + TABLE_ROWS + FROM + information_schema.partitions + WHERE + TABLE_SCHEMA = '{db_name}' + AND TABLE_NAME = '{table_name}' + """ + + partitions = execute_query(query) + + if not partitions: + return {} + + partition_info = { + "has_partitions": True, + "partitions": [] + } + + for part in partitions: + partition_info["partitions"].append({ + "name": part.get("PARTITION_NAME", ""), + "expression": part.get("PARTITION_EXPRESSION", ""), + "description": part.get("PARTITION_DESCRIPTION", ""), + "rows": part.get("TABLE_ROWS", 0) + }) + + return partition_info + except Exception as e: + logger.error(f"Error getting partition information for table {db_name}.{table_name}: {str(e)}") + return {} \ No newline at end of file diff --git a/doris_mcp_server/utils/sql_executor_tools.py b/doris_mcp_server/utils/sql_executor_tools.py new file mode 100644 index 0000000..1b6346b --- /dev/null +++ b/doris_mcp_server/utils/sql_executor_tools.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +SQL Execution Tool + +Responsible for executing SQL queries and handling results +""" + +import os +import json +import logging +import traceback +import time +from typing import Dict, Any +import re +import datetime +from decimal import Decimal + +# Get logger +logger = logging.getLogger("doris-mcp.sql-executor") + +# Add environment variable control for whether to perform SQL security checks +ENABLE_SQL_SECURITY_CHECK = os.environ.get('ENABLE_SQL_SECURITY_CHECK', 'true').lower() == 'true' + +async def execute_sql_query(ctx) -> Dict[str, Any]: + """ + Execute SQL query and return results + + Args: + ctx: Context object or dictionary containing request parameters + + Returns: + Dict[str, Any]: Execution result + """ + try: + # Support the case where the passed argument is a dictionary + if isinstance(ctx, dict) and 'params' in ctx: + params = ctx['params'] + else: + params = ctx.params + + sql = params.get("sql") + db_name = params.get("db_name", os.getenv("DB_DATABASE", "")) + max_rows = params.get("max_rows", 1000) # Maximum number of rows to return + timeout = params.get("timeout", 30) # Timeout in seconds + + if not sql: + return { + "content": [ + { + "type": "text", + "text": json.dumps({ + "success": False, + "error": "Missing SQL parameter", + "message": "Please provide the SQL query to execute" + }, ensure_ascii=False) + } + ] + } + + # First check SQL security + security_result = await _check_sql_security(sql) + if not security_result.get("is_safe", False): + return { + "content": [ + { + "type": "text", + "text": json.dumps({ + "success": False, + "error": "SQL security check failed", + "message": "Query contains unsafe operations and cannot be executed", + "security_issues": security_result.get("security_issues", []) + }, ensure_ascii=False) + } + ] + } + + # Import database connection tool + from doris_mcp_server.utils.db import execute_query + + if not sql: + return { + "content": [ + { + "type": "text", + "text": json.dumps({ + "success": False, + "error": "Missing SQL parameter", + "message": "Please provide the SQL query to execute" + }, ensure_ascii=False) + } + ] + } + + # Ensure SELECT statements include a LIMIT clause + sql_lower = sql.lower().strip() + if sql_lower.startswith("select") and "limit" not in sql_lower: + sql = sql.rstrip(";") + f" LIMIT {max_rows};" + + # Start timer + start_time = time.time() + + # Execute query + try: + result = execute_query(sql, db_name) + + # Calculate execution time + execution_time = time.time() - start_time + + # Build return result + if isinstance(result, list): + # Handle list of query results + row_count = len(result) + + # Extract column names + if hasattr(result[0], "_fields"): + # If it's a named tuple + columns = list(result[0]._fields) + else: + # Otherwise, assume it's a dictionary + columns = list(result[0].keys()) if isinstance(result[0], dict) else [] + + # Convert results to serializable format + data = [] + for row in result: + row_dict = {} + if hasattr(row, "_asdict"): + # If it's a named tuple + row_dict = row._asdict() + elif isinstance(row, dict): + # If it's a dictionary + row_dict = row + else: + # If it's a list or tuple + row_dict = dict(zip(columns, row)) if columns else row + + # Handle special types to make them JSON serializable + serialized_row = _serialize_row_data(row_dict) + data.append(serialized_row) + + return { + "content": [ + { + "type": "text", + "text": json.dumps({ + "success": True, + "sql": sql, + "row_count": row_count, + "columns": columns, + "data": data[:max_rows], # Limit returned rows + "execution_time": execution_time, + "truncated": row_count > max_rows + }, ensure_ascii=False) + } + ] + } + else: + # Handle other types of results + other_response = { + "success": True, + "sql": sql, + "result": str(result), + "execution_time": execution_time + } + other_response = _serialize_row_data(other_response) + return { + "content": [ + { + "type": "text", + "text": json.dumps(other_response, ensure_ascii=False) + } + ] + } + + except Exception as db_error: + error_message = str(db_error) + + # Try to get more detailed error information + error_details = {} + if "timeout" in error_message.lower(): + error_details["type"] = "timeout" + error_details["suggestion"] = "Query timed out, please optimize SQL or increase timeout" + elif "syntax" in error_message.lower(): + error_details["type"] = "syntax" + error_details["suggestion"] = "SQL syntax error, please check syntax" + elif "not found" in error_message.lower() or "doesn't exist" in error_message.lower(): + error_details["type"] = "not_found" + error_details["suggestion"] = "Table or column not found, please check table and column names" + else: + error_details["type"] = "unknown" + error_details["suggestion"] = "Please check the SQL statement and try simplifying the query" + + # Create error response + error_response = { + "success": False, + "error": error_message, + "error_details": error_details, + "sql": sql, + "db_name": db_name + } + + # Ensure error response is also serializable + error_response = _serialize_row_data(error_response) + + return { + "content": [ + { + "type": "text", + "text": json.dumps(error_response, ensure_ascii=False) + } + ] + } + + except Exception as e: + logger.error(f"Failed to execute SQL query: {str(e)}") + logger.error(traceback.format_exc()) + + error_response = { + "success": False, + "error": str(e), + "message": "Error occurred while executing SQL query" + } + + # Ensure error response is also serializable + error_response = _serialize_row_data(error_response) + + return { + "content": [ + { + "type": "text", + "text": json.dumps(error_response, ensure_ascii=False) + } + ] + } + +# Helper function +async def _check_sql_security(sql: str) -> Dict[str, Any]: + """Check SQL security""" + # If environment variable is set to disable security check, return safe immediately + if not ENABLE_SQL_SECURITY_CHECK: + return { + "is_safe": True, + "security_issues": [] + } + + # Check if SQL contains dangerous operations + sql_lower = sql.lower() + + # Check if it's a read-only query type + is_read_only = sql_lower.strip().startswith(("select ", "show ", "desc ", "describe ", "explain ")) + + # Define list of dangerous operations (checked for both read-only and non-read-only queries) + dangerous_operations = [ + (r'\bdelete\b', "DELETE operation"), + (r'\bdrop\b', "DROP TABLE/DATABASE operation"), + (r'\btruncate\b', "TRUNCATE TABLE operation"), + (r'\bupdate\b', "UPDATE operation"), + (r'\binsert\b', "INSERT operation"), + (r'\balter\b', "ALTER TABLE structure operation"), + (r'\bcreate\b', "CREATE TABLE/DATABASE operation"), + (r'\bgrant\b', "GRANT operation"), + (r'\brevoke\b', "REVOKE permission operation"), + (r'\bexec\b', "EXECUTE stored procedure"), + (r'\bxp_', "Extended stored procedure, potential security risk"), + (r'\bshutdown\b', "SHUTDOWN database operation"), + (r'\bunion\s+all\s+select\b', "UNION statement, potential SQL injection"), + (r'\bunion\s+select\b', "UNION statement, potential SQL injection"), + (r'\binto\s+outfile\b', "Write to file operation"), + (r'\bload_file\b', "Load file operation") + ] + + # Dangerous operations checked only for non-read-only queries + non_readonly_operations = [] + if not is_read_only: + non_readonly_operations = [ + (r'--', "SQL comment, potential SQL injection"), + (r'/\*', "SQL block comment, potential SQL injection") + ] + + # Check if dangerous operations are included + security_issues = [] + + # Check dangerous operations applicable to all queries + for operation, description in dangerous_operations: + if re.search(operation, sql_lower): + # For specific keywords in read-only queries, differentiate if used as independent operations + if is_read_only and operation in [r'\bcreate\b', r'\bdrop\b', r'\bdelete\b', r'\binsert\b', r'\bupdate\b', r'\balter\b']: + # Check if used as DDL/DML keyword, e.g., CREATE TABLE, DROP DATABASE + pattern = operation + r'\s+(?:table|database|view|index|procedure|function|trigger|event)' + if re.search(pattern, sql_lower): + security_issues.append({ + "operation": operation.replace(r'\b', '').replace(r'\s+', ' '), + "description": description, + "severity": "High" + }) + else: + security_issues.append({ + "operation": operation.replace(r'\b', '').replace(r'\s+', ' '), + "description": description, + "severity": "High" + }) + + # Check dangerous operations specific to non-read-only queries + for operation, description in non_readonly_operations: + if re.search(operation, sql_lower): + security_issues.append({ + "operation": operation.replace(r'\b', '').replace(r'\s+', ' '), + "description": description, + "severity": "Medium" + }) + + return { + "is_safe": len(security_issues) == 0, + "security_issues": security_issues + } + +def _serialize_row_data(row_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert special types in row data (like date, time, Decimal) to JSON serializable format + + Args: + row_data: Row data dictionary + + Returns: + Dict[str, Any]: Processed serializable dictionary + """ + serialized_data = {} + for key, value in row_data.items(): + if value is None: + serialized_data[key] = None + elif isinstance(value, (datetime.date, datetime.datetime)): + # Convert date and time types to ISO format string + serialized_data[key] = value.isoformat() + elif isinstance(value, Decimal): + # Convert Decimal type to float + serialized_data[key] = float(value) + elif isinstance(value, (list, tuple)): + # Recursively process elements in list or tuple + serialized_data[key] = [ + _serialize_row_data(item) if isinstance(item, dict) else item + for item in value + ] + elif isinstance(value, dict): + # Recursively process nested dictionaries + serialized_data[key] = _serialize_row_data(value) + else: + serialized_data[key] = value + return serialized_data \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..5fbeb96 --- /dev/null +++ b/env.example @@ -0,0 +1,61 @@ +# Doris MCP Server Example Configuration File +# Copy this file to .env and modify it for your configuration +# Comment out unused configuration items with # + +#=============================== +# Database Configuration +#=============================== +# Database connection information +DB_HOST=localhost +DB_PORT=9030 +DB_WEB_PORT=8030 +DB_USER=root +DB_PASSWORD= +# Default database +DB_DATABASE=test + +# Multi-database support +# ENABLE_MULTI_DATABASE=false +# List of multi-database names (different databases using the same connection), JSON array format +# MULTI_DATABASE_NAMES=["test", "sales", "user", "product"] + +#=============================== +# Table Hierarchy Matching Configuration +#=============================== +# Whether to enable table hierarchy priority matching +# ENABLE_TABLE_HIERARCHY_MATCHING=false +# Table hierarchy matching regular expressions, sorted by priority from high to low, JSON format +# TABLE_HIERARCHY_PATTERNS=["^ads_.*$","^dim_.*$","^dws_.*$","^dwd_.*$","^ods_.*$","^tmp_.*$","^stg_.*$","^.*$"] +# Table hierarchy matching timeout (seconds) +# TABLE_HIERARCHY_TIMEOUT=10 + +# List of excluded databases, these databases will not be scanned and metadata processed, JSON format +# EXCLUDED_DATABASES=["information_schema", "mysql", "performance_schema", "sys", "doris_metadata"] + + +#=============================== +# Server Configuration +#=============================== +SERVER_HOST=0.0.0.0 +SERVER_PORT=3000 +# LOG_LEVEL=INFO # Defined below + +# Cache Configuration +CACHE_TTL=86400 + +#=============================== +# Logging Configuration +#=============================== +# Log directory path +LOG_DIR=logs +# Log file prefix +LOG_PREFIX=doris_mcp +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO +# Log retention days +LOG_MAX_DAYS=30 +# Whether to enable console log output (should be set to false when running as a service) +CONSOLE_LOGGING=false + +# CORS Configuration +ALLOWED_ORIGINS=* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1cbfd95 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "doris-mcp" +version = "0.2.0" +description = "Doris MCP Server for Cursor integration" +readme = "README.md" +requires-python = ">=3.12" +license = "Apache-2.0" +authors = [ + {name = "Doris MCP Team - Yijia Su"} +] + +dependencies = [ + "mcp[cli]>=1.0.0", + "pymysql>=1.0.2", + "pandas>=1.5.0", + "numpy>=1.20.0", + "scikit-learn>=1.0.0", + "python-dotenv>=0.19.0", + "pydantic>=1.10.0", + "requests>=2.28.0", + "openai>=1.66.3", + "fastapi>=0.95.0", + "uvicorn>=0.21.0", + "simplejson>=3.17.0" +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "isort>=5.12.0" +] + +[project.scripts] +# Updated entry point for stdio mode back to mcp_core +doris-mcp = "doris_mcp_server.mcp_core:run_stdio" + +[tool.setuptools] +# Explicitly list the package found in the root directory +packages = ["doris_mcp_server"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb61f0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +mcp[cli]>=1.0.0 +pymysql>=1.0.2 +pandas>=1.5.0 +numpy>=1.20.0 +scikit-learn>=1.0.0 +python-dotenv>=0.19.0 +pydantic>=1.10.0 +requests>=2.28.0 +openai>=1.66.3 +uv>=0.6.8 +psutil>=5.9.0 +simplejson>=3.17.0 +fastapi>=0.115.4 +uvicorn>=0.29.0 +sse-starlette>=1.6.5 diff --git a/restart_server.sh b/restart_server.sh new file mode 100755 index 0000000..c4638d5 --- /dev/null +++ b/restart_server.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# Doris MCP Server Restart Script +# Detects port and process usage, terminates existing processes, then restarts the server + +# Set terminal colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +# Server configuration +MCP_PORT=3000 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +START_SCRIPT="${SCRIPT_DIR}/start_server.sh" + +echo -e "${GREEN}========== Doris MCP Server Restart Script ==========${NC}" + +# Check if start_server.sh exists +if [ ! -f "$START_SCRIPT" ]; then + echo -e "${RED}Error: Start script $START_SCRIPT does not exist${NC}" + exit 1 +fi + +# Check port usage +check_port() { + echo -e "${YELLOW}Checking port $MCP_PORT usage...${NC}" + PORT_PID=$(lsof -ti:$MCP_PORT) + if [ -n "$PORT_PID" ]; then + echo -e "${YELLOW}Port $MCP_PORT is used by process $PORT_PID${NC}" + return 0 + else + echo -e "${GREEN}Port $MCP_PORT is not in use${NC}" + return 1 + fi +} + +# Check if Python process is running +check_python_process() { + echo -e "${YELLOW}Checking if Python process is running doris_mcp_server.main...${NC}" + PYTHON_PID=$(ps aux | grep "[p]ython.*-m doris_mcp_server.main --sse" | awk '{print $2}') + if [ -n "$PYTHON_PID" ]; then + echo -e "${YELLOW}Detected Python process $PYTHON_PID running doris_mcp_server.main --sse${NC}" + return 0 + else + echo -e "${GREEN}No Python process running doris_mcp_server.main detected${NC}" + return 1 + fi +} + +# Kill process +kill_process() { + local PID=$1 + echo -e "${YELLOW}Terminating process $PID...${NC}" + kill $PID 2>/dev/null + + # Wait for process termination + for i in {1..5}; do + if ! ps -p $PID > /dev/null 2>&1; then + echo -e "${GREEN}Process $PID has terminated${NC}" + return 0 + fi + echo -e "${YELLOW}Waiting for process termination (${i}/5)...${NC}" + sleep 1 + done + + # If process is still running, force kill + if ps -p $PID > /dev/null 2>&1; then + echo -e "${YELLOW}Process still running, force killing process $PID...${NC}" + kill -9 $PID 2>/dev/null + sleep 1 + if ! ps -p $PID > /dev/null 2>&1; then + echo -e "${GREEN}Process $PID has been force killed${NC}" + return 0 + else + echo -e "${RED}Failed to terminate process $PID${NC}" + return 1 + fi + fi + + return 0 +} + +# Clean up all process and port usage +cleanup() { + # Check and terminate process using the port + check_port + if [ $? -eq 0 ]; then + kill_process $PORT_PID + fi + + # Check and terminate Python process + check_python_process + if [ $? -eq 0 ]; then + kill_process $PYTHON_PID + fi + + # Check port usage again to ensure it's released + check_port + if [ $? -eq 0 ]; then + echo -e "${RED}Warning: Failed to release port $MCP_PORT, please check the process manually${NC}" + return 1 + fi + + # Clean up possible Python bytecode cache + echo -e "${YELLOW}Cleaning Python bytecode cache...${NC}" + find "$SCRIPT_DIR" -name "*.pyc" -delete + find "$SCRIPT_DIR" -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + + echo -e "${GREEN}Cleanup complete${NC}" + return 0 +} + +# Start server +start_server() { + echo -e "${YELLOW}Stopping existing Doris MCP server process (SSE mode)...${NC}" + pkill -f "python -m doris_mcp_server.main --sse" || true + + # Wait for the process to stop completely + sleep 2 + + echo -e "${YELLOW}Starting Doris MCP server (SSE mode)...${NC}" + nohup python -m doris_mcp_server.main --sse >> logs/doris_mcp.log 2>> logs/doris_mcp.error & + + # Wait for server startup + sleep 5 + + echo -e "${YELLOW}Checking if the server started successfully (SSE mode)...${NC}" + if pgrep -f "python -m doris_mcp_server.main --sse" > /dev/null; then + echo -e "${GREEN}Doris MCP server (SSE mode) started successfully${NC}" + echo -e "${GREEN}Service address: http://localhost:$MCP_PORT/${NC}" + return 0 + else + echo -e "${RED}Server startup failed, please check the log files${NC}" + tail -n 20 logs/doris_mcp.error + return 1 + fi +} + +# Main function +main() { + echo -e "${YELLOW}Starting Doris MCP server restart...${NC}" + + # Clean up existing processes + cleanup + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to clean up existing processes, restart aborted${NC}" + exit 1 + fi + + # Wait for port to be fully released + sleep 2 + + # Start the server + start_server + if [ $? -ne 0 ]; then + echo -e "${RED}Server startup failed${NC}" + exit 1 + fi + + echo -e "${GREEN}Server restarted successfully${NC}" + echo -e "${YELLOW}Service running at: http://localhost:$MCP_PORT${NC}" + echo -e "${YELLOW}Health check: http://localhost:$MCP_PORT/health${NC}" + echo -e "${YELLOW}SSE test endpoint: http://localhost:$MCP_PORT/sse" +} + +# Run main function +main \ No newline at end of file diff --git a/start_server.sh b/start_server.sh new file mode 100755 index 0000000..7065cd0 --- /dev/null +++ b/start_server.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Doris MCP Server Start Script +# Ensures the service runs in SSE mode + +# Set colors +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}========== Doris MCP Server Start Script ==========${NC}" + +# Check virtual environment +if [ -d "venv" ]; then + echo -e "${CYAN}Virtual environment found, activating...${NC}" # Found virtual environment, activating... + source venv/bin/activate +fi + +# Clean cache files +echo -e "${CYAN}Cleaning cache files...${NC}" # Cleaning cache files... +echo -e "${CYAN}Cleaning Python cache files...${NC}" # Cleaning Python cache files... +find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true +echo -e "${CYAN}Cleaning temporary files...${NC}" # Cleaning temporary files... +rm -rf .pytest_cache 2>/dev/null || true +echo -e "${CYAN}Cleaning log files...${NC}" # Cleaning log files... +find ./log -type f -name "*.log" -delete 2>/dev/null || true + +# Reload environment variables +if [ -f .env ]; then + echo -e "${CYAN}Loading environment variables from .env file...${NC}" # Loading environment variables from .env file... + source .env +fi + +# Output key environment variables before starting +echo -e "${CYAN}Database settings:${NC}" # Database settings: +echo "DB_HOST=${DB_HOST}" +echo "DB_PORT=${DB_PORT}" +echo "DB_DATABASE=${DB_DATABASE}" +echo "FORCE_REFRESH_METADATA=${FORCE_REFRESH_METADATA}" + +# Start the server (using -m and new package path) +python -m doris_mcp_server.main --sse + +# Clean cache files (This section seems redundant and possibly misplaced after the server starts) +echo -e "${YELLOW}Cleaning cache files...${NC}" # Cleaning cache files... + +# Backend cache cleanup +echo -e "${GREEN}Cleaning Python cache files...${NC}" # Cleaning Python cache files... +find . -type d -name "__pycache__" -exec rm -rf {} + +find . -type f -name "*.pyc" -delete +rm -rf ./.pytest_cache + +# Clean temporary files +echo -e "${GREEN}Cleaning temporary files...${NC}" # Cleaning temporary files... +rm -rf ./tmp +mkdir -p tmp + +# Clean log files +echo -e "${GREEN}Cleaning log files...${NC}" # Cleaning log files... +rm -rf ./logs/*.log +mkdir -p logs + +# Set environment variables, force SSE mode (This section also seems redundant if variables are set in .env and the command uses --sse) +export MCP_PORT=3000 +export ALLOWED_ORIGINS="*" +export LOG_LEVEL="info" +export MCP_ALLOW_CREDENTIALS="false" + +# Add adapter debug support +export MCP_DEBUG_ADAPTER="true" +export PYTHONPATH="$(pwd):$PYTHONPATH" # Ensure modules can be imported + +# Create log directory +mkdir -p logs + +# Debug info +echo -e "${GREEN}Environment Variables:${NC}" # Environment Variables: +echo -e "MCP_TRANSPORT_TYPE=${MCP_TRANSPORT_TYPE}" +echo -e "MCP_PORT=${MCP_PORT}" +echo -e "ALLOWED_ORIGINS=${ALLOWED_ORIGINS}" +echo -e "LOG_LEVEL=${LOG_LEVEL}" +echo -e "MCP_ALLOW_CREDENTIALS=${MCP_ALLOW_CREDENTIALS}" +echo -e "MCP_DEBUG_ADAPTER=${MCP_DEBUG_ADAPTER}" + +echo -e "${GREEN}Starting MCP server (SSE mode)...${NC}" # Starting MCP server (SSE mode)... +echo -e "${YELLOW}Service will run on http://localhost:3000/mcp${NC}" # Service will run on http://localhost:3000/mcp +echo -e "${YELLOW}Health Check: http://localhost:3000/health${NC}" # Health Check: http://localhost:3000/health +echo -e "${YELLOW}SSE Test: http://localhost:3000/sse${NC}" # SSE Test: http://localhost:3000/sse +echo -e "${YELLOW}Use Ctrl+C to stop the service${NC}" # Use Ctrl+C to stop the service + +# If the server exits abnormally, output error message +if [ $? -ne 0 ]; then + echo -e "${RED}Server exited abnormally! Check logs for more information${NC}" # Server exited abnormally! Check logs for more information + exit 1 +fi + +# Show browser cache clearing prompt +echo -e "${YELLOW}Tip: If the page displays abnormally, please clear your browser cache or use incognito mode${NC}" # Tip: If the page displays abnormally, please clear your browser cache or use incognito mode +echo -e "${YELLOW}Chrome browser clear cache shortcut: Ctrl+Shift+Del (Windows) or Cmd+Shift+Del (Mac)${NC}" # Chrome browser clear cache shortcut: Ctrl+Shift+Del (Windows) or Cmd+Shift+Del (Mac) \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d847d22 --- /dev/null +++ b/uv.lock @@ -0,0 +1,886 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186 }, +] + +[[package]] +name = "jiter" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 }, + { url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 }, + { url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 }, + { url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 }, + { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 }, +] + +[[package]] +name = "joblib" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/33/60135848598c076ce4b231e1b1895170f45fbcaeaa2c9d5e38b04db70c35/joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e", size = 2116621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mcp" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mcp-doris" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "mcp", extra = ["cli"] }, + { name = "numpy" }, + { name = "openai" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "pymysql" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "scikit-learn" }, + { name = "simplejson" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "isort" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "fastapi", specifier = ">=0.95.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.0.0" }, + { name = "numpy", specifier = ">=1.20.0" }, + { name = "openai", specifier = ">=1.66.3" }, + { name = "pandas", specifier = ">=1.5.0" }, + { name = "pydantic", specifier = ">=1.10.0" }, + { name = "pymysql", specifier = ">=1.0.2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "python-dotenv", specifier = ">=0.19.0" }, + { name = "requests", specifier = ">=2.28.0" }, + { name = "scikit-learn", specifier = ">=1.0.0" }, + { name = "simplejson", specifier = ">=3.17.0" }, + { name = "uvicorn", specifier = ">=0.21.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "numpy" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/f7/1fd4ff108cd9d7ef929b8882692e23665dc9c23feecafbb9c6b80f4ec583/numpy-2.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ee461a4eaab4f165b68780a6a1af95fb23a29932be7569b9fab666c407969051", size = 20948633 }, + { url = "https://files.pythonhosted.org/packages/12/03/d443c278348371b20d830af155ff2079acad6a9e60279fac2b41dbbb73d8/numpy-2.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec31367fd6a255dc8de4772bd1658c3e926d8e860a0b6e922b615e532d320ddc", size = 14176123 }, + { url = "https://files.pythonhosted.org/packages/2b/0b/5ca264641d0e7b14393313304da48b225d15d471250376f3fbdb1a2be603/numpy-2.2.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47834cde750d3c9f4e52c6ca28a7361859fcaf52695c7dc3cc1a720b8922683e", size = 5163817 }, + { url = "https://files.pythonhosted.org/packages/04/b3/d522672b9e3d28e26e1613de7675b441bbd1eaca75db95680635dd158c67/numpy-2.2.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2c1a1c6ccce4022383583a6ded7bbcda22fc635eb4eb1e0a053336425ed36dfa", size = 6698066 }, + { url = "https://files.pythonhosted.org/packages/a0/93/0f7a75c1ff02d4b76df35079676b3b2719fcdfb39abdf44c8b33f43ef37d/numpy-2.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d75f338f5f79ee23548b03d801d28a505198297534f62416391857ea0479571", size = 14087277 }, + { url = "https://files.pythonhosted.org/packages/b0/d9/7c338b923c53d431bc837b5b787052fef9ae68a56fe91e325aac0d48226e/numpy-2.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a801fef99668f309b88640e28d261991bfad9617c27beda4a3aec4f217ea073", size = 16135742 }, + { url = "https://files.pythonhosted.org/packages/2d/10/4dec9184a5d74ba9867c6f7d1e9f2e0fb5fe96ff2bf50bb6f342d64f2003/numpy-2.2.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:abe38cd8381245a7f49967a6010e77dbf3680bd3627c0fe4362dd693b404c7f8", size = 15581825 }, + { url = "https://files.pythonhosted.org/packages/80/1f/2b6fcd636e848053f5b57712a7d1880b1565eec35a637fdfd0a30d5e738d/numpy-2.2.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a0ac90e46fdb5649ab6369d1ab6104bfe5854ab19b645bf5cda0127a13034ae", size = 17899600 }, + { url = "https://files.pythonhosted.org/packages/ec/87/36801f4dc2623d76a0a3835975524a84bd2b18fe0f8835d45c8eae2f9ff2/numpy-2.2.5-cp312-cp312-win32.whl", hash = "sha256:0cd48122a6b7eab8f06404805b1bd5856200e3ed6f8a1b9a194f9d9054631beb", size = 6312626 }, + { url = "https://files.pythonhosted.org/packages/8b/09/4ffb4d6cfe7ca6707336187951992bd8a8b9142cf345d87ab858d2d7636a/numpy-2.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:ced69262a8278547e63409b2653b372bf4baff0870c57efa76c5703fd6543282", size = 12645715 }, + { url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102 }, + { url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709 }, + { url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173 }, + { url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502 }, + { url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417 }, + { url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611 }, + { url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594 }, + { url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356 }, + { url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778 }, + { url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279 }, + { url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247 }, + { url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087 }, + { url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964 }, + { url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214 }, + { url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788 }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672 }, + { url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102 }, + { url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096 }, +] + +[[package]] +name = "openai" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/51/817969ec969b73d8ddad085670ecd8a45ef1af1811d8c3b8a177ca4d1309/openai-1.76.0.tar.gz", hash = "sha256:fd2bfaf4608f48102d6b74f9e11c5ecaa058b60dad9c36e409c12477dfd91fb2", size = 434660 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/aa/84e02ab500ca871eb8f62784426963a1c7c17a72fea3c7f268af4bbaafa5/openai-1.76.0-py3-none-any.whl", hash = "sha256:a712b50e78cf78e6d7b2a8f69c4978243517c2c36999756673e07a14ce37dc0a", size = 661201 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pymysql" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/ce59b5e5ed4ce8512f879ff1fa5ab699d211ae2495f1adaa5fbba2a1eada/pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0", size = 47678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/94/e4181a1f6286f545507528c78016e00065ea913276888db2262507693ce5/PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c", size = 44972 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "scikit-learn" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/a5/4ae3b3a0755f7b35a280ac90b28817d1f380318973cff14075ab41ef50d9/scikit_learn-1.6.1.tar.gz", hash = "sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e", size = 7068312 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/18/c797c9b8c10380d05616db3bfb48e2a3358c767affd0857d56c2eb501caa/scikit_learn-1.6.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:926f207c804104677af4857b2c609940b743d04c4c35ce0ddc8ff4f053cddc1b", size = 12104516 }, + { url = "https://files.pythonhosted.org/packages/c4/b7/2e35f8e289ab70108f8cbb2e7a2208f0575dc704749721286519dcf35f6f/scikit_learn-1.6.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2c2cae262064e6a9b77eee1c8e768fc46aa0b8338c6a8297b9b6759720ec0ff2", size = 11167837 }, + { url = "https://files.pythonhosted.org/packages/a4/f6/ff7beaeb644bcad72bcfd5a03ff36d32ee4e53a8b29a639f11bcb65d06cd/scikit_learn-1.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1061b7c028a8663fb9a1a1baf9317b64a257fcb036dae5c8752b2abef31d136f", size = 12253728 }, + { url = "https://files.pythonhosted.org/packages/29/7a/8bce8968883e9465de20be15542f4c7e221952441727c4dad24d534c6d99/scikit_learn-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e69fab4ebfc9c9b580a7a80111b43d214ab06250f8a7ef590a4edf72464dd86", size = 13147700 }, + { url = "https://files.pythonhosted.org/packages/62/27/585859e72e117fe861c2079bcba35591a84f801e21bc1ab85bce6ce60305/scikit_learn-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:70b1d7e85b1c96383f872a519b3375f92f14731e279a7b4c6cfd650cf5dffc52", size = 11110613 }, + { url = "https://files.pythonhosted.org/packages/2e/59/8eb1872ca87009bdcdb7f3cdc679ad557b992c12f4b61f9250659e592c63/scikit_learn-1.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ffa1e9e25b3d93990e74a4be2c2fc61ee5af85811562f1288d5d055880c4322", size = 12010001 }, + { url = "https://files.pythonhosted.org/packages/9d/05/f2fc4effc5b32e525408524c982c468c29d22f828834f0625c5ef3d601be/scikit_learn-1.6.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dc5cf3d68c5a20ad6d571584c0750ec641cc46aeef1c1507be51300e6003a7e1", size = 11096360 }, + { url = "https://files.pythonhosted.org/packages/c8/e4/4195d52cf4f113573fb8ebc44ed5a81bd511a92c0228889125fac2f4c3d1/scikit_learn-1.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c06beb2e839ecc641366000ca84f3cf6fa9faa1777e29cf0c04be6e4d096a348", size = 12209004 }, + { url = "https://files.pythonhosted.org/packages/94/be/47e16cdd1e7fcf97d95b3cb08bde1abb13e627861af427a3651fcb80b517/scikit_learn-1.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8ca8cb270fee8f1f76fa9bfd5c3507d60c6438bbee5687f81042e2bb98e5a97", size = 13171776 }, + { url = "https://files.pythonhosted.org/packages/34/b0/ca92b90859070a1487827dbc672f998da95ce83edce1270fc23f96f1f61a/scikit_learn-1.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:7a1c43c8ec9fde528d664d947dc4c0789be4077a3647f232869f41d9bf50e0fb", size = 11071865 }, + { url = "https://files.pythonhosted.org/packages/12/ae/993b0fb24a356e71e9a894e42b8a9eec528d4c70217353a1cd7a48bc25d4/scikit_learn-1.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a17c1dea1d56dcda2fac315712f3651a1fea86565b64b48fa1bc090249cbf236", size = 11955804 }, + { url = "https://files.pythonhosted.org/packages/d6/54/32fa2ee591af44507eac86406fa6bba968d1eb22831494470d0a2e4a1eb1/scikit_learn-1.6.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a7aa5f9908f0f28f4edaa6963c0a6183f1911e63a69aa03782f0d924c830a35", size = 11100530 }, + { url = "https://files.pythonhosted.org/packages/3f/58/55856da1adec655bdce77b502e94a267bf40a8c0b89f8622837f89503b5a/scikit_learn-1.6.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0650e730afb87402baa88afbf31c07b84c98272622aaba002559b614600ca691", size = 12433852 }, + { url = "https://files.pythonhosted.org/packages/ff/4f/c83853af13901a574f8f13b645467285a48940f185b690936bb700a50863/scikit_learn-1.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:3f59fe08dc03ea158605170eb52b22a105f238a5d512c4470ddeca71feae8e5f", size = 11337256 }, +] + +[[package]] +name = "scipy" +version = "1.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/5d/3c78815cbab499610f26b5bae6aed33e227225a9fa5290008a733a64f6fc/scipy-1.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c4697a10da8f8765bb7c83e24a470da5797e37041edfd77fd95ba3811a47c4fd", size = 38756184 }, + { url = "https://files.pythonhosted.org/packages/37/20/3d04eb066b471b6e171827548b9ddb3c21c6bbea72a4d84fc5989933910b/scipy-1.15.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:869269b767d5ee7ea6991ed7e22b3ca1f22de73ab9a49c44bad338b725603301", size = 30163558 }, + { url = "https://files.pythonhosted.org/packages/a4/98/e5c964526c929ef1f795d4c343b2ff98634ad2051bd2bbadfef9e772e413/scipy-1.15.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bad78d580270a4d32470563ea86c6590b465cb98f83d760ff5b0990cb5518a93", size = 22437211 }, + { url = "https://files.pythonhosted.org/packages/1d/cd/1dc7371e29195ecbf5222f9afeedb210e0a75057d8afbd942aa6cf8c8eca/scipy-1.15.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:b09ae80010f52efddb15551025f9016c910296cf70adbf03ce2a8704f3a5ad20", size = 25232260 }, + { url = "https://files.pythonhosted.org/packages/f0/24/1a181a9e5050090e0b5138c5f496fee33293c342b788d02586bc410c6477/scipy-1.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6fd6eac1ce74a9f77a7fc724080d507c5812d61e72bd5e4c489b042455865e", size = 35198095 }, + { url = "https://files.pythonhosted.org/packages/c0/53/eaada1a414c026673eb983f8b4a55fe5eb172725d33d62c1b21f63ff6ca4/scipy-1.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b871df1fe1a3ba85d90e22742b93584f8d2b8e6124f8372ab15c71b73e428b8", size = 37297371 }, + { url = "https://files.pythonhosted.org/packages/e9/06/0449b744892ed22b7e7b9a1994a866e64895363572677a316a9042af1fe5/scipy-1.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:03205d57a28e18dfd39f0377d5002725bf1f19a46f444108c29bdb246b6c8a11", size = 36872390 }, + { url = "https://files.pythonhosted.org/packages/6a/6f/a8ac3cfd9505ec695c1bc35edc034d13afbd2fc1882a7c6b473e280397bb/scipy-1.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:601881dfb761311045b03114c5fe718a12634e5608c3b403737ae463c9885d53", size = 39700276 }, + { url = "https://files.pythonhosted.org/packages/f5/6f/e6e5aff77ea2a48dd96808bb51d7450875af154ee7cbe72188afb0b37929/scipy-1.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:e7c68b6a43259ba0aab737237876e5c2c549a031ddb7abc28c7b47f22e202ded", size = 40942317 }, + { url = "https://files.pythonhosted.org/packages/53/40/09319f6e0f276ea2754196185f95cd191cb852288440ce035d5c3a931ea2/scipy-1.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01edfac9f0798ad6b46d9c4c9ca0e0ad23dbf0b1eb70e96adb9fa7f525eff0bf", size = 38717587 }, + { url = "https://files.pythonhosted.org/packages/fe/c3/2854f40ecd19585d65afaef601e5e1f8dbf6758b2f95b5ea93d38655a2c6/scipy-1.15.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:08b57a9336b8e79b305a143c3655cc5bdbe6d5ece3378578888d2afbb51c4e37", size = 30100266 }, + { url = "https://files.pythonhosted.org/packages/dd/b1/f9fe6e3c828cb5930b5fe74cb479de5f3d66d682fa8adb77249acaf545b8/scipy-1.15.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:54c462098484e7466362a9f1672d20888f724911a74c22ae35b61f9c5919183d", size = 22373768 }, + { url = "https://files.pythonhosted.org/packages/15/9d/a60db8c795700414c3f681908a2b911e031e024d93214f2d23c6dae174ab/scipy-1.15.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:cf72ff559a53a6a6d77bd8eefd12a17995ffa44ad86c77a5df96f533d4e6c6bb", size = 25154719 }, + { url = "https://files.pythonhosted.org/packages/37/3b/9bda92a85cd93f19f9ed90ade84aa1e51657e29988317fabdd44544f1dd4/scipy-1.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de9d1416b3d9e7df9923ab23cd2fe714244af10b763975bea9e4f2e81cebd27", size = 35163195 }, + { url = "https://files.pythonhosted.org/packages/03/5a/fc34bf1aa14dc7c0e701691fa8685f3faec80e57d816615e3625f28feb43/scipy-1.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb530e4794fc8ea76a4a21ccb67dea33e5e0e60f07fc38a49e821e1eae3b71a0", size = 37255404 }, + { url = "https://files.pythonhosted.org/packages/4a/71/472eac45440cee134c8a180dbe4c01b3ec247e0338b7c759e6cd71f199a7/scipy-1.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5ea7ed46d437fc52350b028b1d44e002646e28f3e8ddc714011aaf87330f2f32", size = 36860011 }, + { url = "https://files.pythonhosted.org/packages/01/b3/21f890f4f42daf20e4d3aaa18182dddb9192771cd47445aaae2e318f6738/scipy-1.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11e7ad32cf184b74380f43d3c0a706f49358b904fa7d5345f16ddf993609184d", size = 39657406 }, + { url = "https://files.pythonhosted.org/packages/0d/76/77cf2ac1f2a9cc00c073d49e1e16244e389dd88e2490c91d84e1e3e4d126/scipy-1.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:a5080a79dfb9b78b768cebf3c9dcbc7b665c5875793569f48bf0e2b1d7f68f6f", size = 40961243 }, + { url = "https://files.pythonhosted.org/packages/4c/4b/a57f8ddcf48e129e6054fa9899a2a86d1fc6b07a0e15c7eebff7ca94533f/scipy-1.15.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:447ce30cee6a9d5d1379087c9e474628dab3db4a67484be1b7dc3196bfb2fac9", size = 38870286 }, + { url = "https://files.pythonhosted.org/packages/0c/43/c304d69a56c91ad5f188c0714f6a97b9c1fed93128c691148621274a3a68/scipy-1.15.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c90ebe8aaa4397eaefa8455a8182b164a6cc1d59ad53f79943f266d99f68687f", size = 30141634 }, + { url = "https://files.pythonhosted.org/packages/44/1a/6c21b45d2548eb73be9b9bff421aaaa7e85e22c1f9b3bc44b23485dfce0a/scipy-1.15.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:def751dd08243934c884a3221156d63e15234a3155cf25978b0a668409d45eb6", size = 22415179 }, + { url = "https://files.pythonhosted.org/packages/74/4b/aefac4bba80ef815b64f55da06f62f92be5d03b467f2ce3668071799429a/scipy-1.15.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:302093e7dfb120e55515936cb55618ee0b895f8bcaf18ff81eca086c17bd80af", size = 25126412 }, + { url = "https://files.pythonhosted.org/packages/b1/53/1cbb148e6e8f1660aacd9f0a9dfa2b05e9ff1cb54b4386fe868477972ac2/scipy-1.15.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd5b77413e1855351cdde594eca99c1f4a588c2d63711388b6a1f1c01f62274", size = 34952867 }, + { url = "https://files.pythonhosted.org/packages/2c/23/e0eb7f31a9c13cf2dca083828b97992dd22f8184c6ce4fec5deec0c81fcf/scipy-1.15.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d0194c37037707b2afa7a2f2a924cf7bac3dc292d51b6a925e5fcb89bc5c776", size = 36890009 }, + { url = "https://files.pythonhosted.org/packages/03/f3/e699e19cabe96bbac5189c04aaa970718f0105cff03d458dc5e2b6bd1e8c/scipy-1.15.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:bae43364d600fdc3ac327db99659dcb79e6e7ecd279a75fe1266669d9a652828", size = 36545159 }, + { url = "https://files.pythonhosted.org/packages/af/f5/ab3838e56fe5cc22383d6fcf2336e48c8fe33e944b9037fbf6cbdf5a11f8/scipy-1.15.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f031846580d9acccd0044efd1a90e6f4df3a6e12b4b6bd694a7bc03a89892b28", size = 39136566 }, + { url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "simplejson" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/92/51b417685abd96b31308b61b9acce7ec50d8e1de8fbc39a7fd4962c60689/simplejson-3.20.1.tar.gz", hash = "sha256:e64139b4ec4f1f24c142ff7dcafe55a22b811a74d86d66560c8815687143037d", size = 85591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/eb/34c16a1ac9ba265d024dc977ad84e1659d931c0a700967c3e59a98ed7514/simplejson-3.20.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f31c4a3a7ab18467ee73a27f3e59158255d1520f3aad74315edde7a940f1be23", size = 93100 }, + { url = "https://files.pythonhosted.org/packages/41/fc/2c2c007d135894971e6814e7c0806936e5bade28f8db4dd7e2a58b50debd/simplejson-3.20.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:884e6183d16b725e113b83a6fc0230152ab6627d4d36cb05c89c2c5bccfa7bc6", size = 75464 }, + { url = "https://files.pythonhosted.org/packages/0f/05/2b5ecb33b776c34bb5cace5de5d7669f9b60e3ca13c113037b2ca86edfbd/simplejson-3.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03d7a426e416fe0d3337115f04164cd9427eb4256e843a6b8751cacf70abc832", size = 75112 }, + { url = "https://files.pythonhosted.org/packages/fe/36/1f3609a2792f06cd4b71030485f78e91eb09cfd57bebf3116bf2980a8bac/simplejson-3.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:000602141d0bddfcff60ea6a6e97d5e10c9db6b17fd2d6c66199fa481b6214bb", size = 150182 }, + { url = "https://files.pythonhosted.org/packages/2f/b0/053fbda38b8b602a77a4f7829def1b4f316cd8deb5440a6d3ee90790d2a4/simplejson-3.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af8377a8af78226e82e3a4349efdde59ffa421ae88be67e18cef915e4023a595", size = 158363 }, + { url = "https://files.pythonhosted.org/packages/d1/4b/2eb84ae867539a80822e92f9be4a7200dffba609275faf99b24141839110/simplejson-3.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15c7de4c88ab2fbcb8781a3b982ef883696736134e20b1210bca43fb42ff1acf", size = 148415 }, + { url = "https://files.pythonhosted.org/packages/e0/bd/400b0bd372a5666addf2540c7358bfc3841b9ce5cdbc5cc4ad2f61627ad8/simplejson-3.20.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:455a882ff3f97d810709f7b620007d4e0aca8da71d06fc5c18ba11daf1c4df49", size = 152213 }, + { url = "https://files.pythonhosted.org/packages/50/12/143f447bf6a827ee9472693768dc1a5eb96154f8feb140a88ce6973a3cfa/simplejson-3.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fc0f523ce923e7f38eb67804bc80e0a028c76d7868500aa3f59225574b5d0453", size = 150048 }, + { url = "https://files.pythonhosted.org/packages/5e/ea/dd9b3e8e8ed710a66f24a22c16a907c9b539b6f5f45fd8586bd5c231444e/simplejson-3.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76461ec929282dde4a08061071a47281ad939d0202dc4e63cdd135844e162fbc", size = 151668 }, + { url = "https://files.pythonhosted.org/packages/99/af/ee52a8045426a0c5b89d755a5a70cc821815ef3c333b56fbcad33c4435c0/simplejson-3.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19c2da8c043607bde4d4ef3a6b633e668a7d2e3d56f40a476a74c5ea71949f", size = 158840 }, + { url = "https://files.pythonhosted.org/packages/68/db/ab32869acea6b5de7d75fa0dac07a112ded795d41eaa7e66c7813b17be95/simplejson-3.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2578bedaedf6294415197b267d4ef678fea336dd78ee2a6d2f4b028e9d07be3", size = 154212 }, + { url = "https://files.pythonhosted.org/packages/fa/7a/e3132d454977d75a3bf9a6d541d730f76462ebf42a96fea2621498166f41/simplejson-3.20.1-cp312-cp312-win32.whl", hash = "sha256:339f407373325a36b7fd744b688ba5bae0666b5d340ec6d98aebc3014bf3d8ea", size = 74101 }, + { url = "https://files.pythonhosted.org/packages/bc/5d/4e243e937fa3560107c69f6f7c2eed8589163f5ed14324e864871daa2dd9/simplejson-3.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:627d4486a1ea7edf1f66bb044ace1ce6b4c1698acd1b05353c97ba4864ea2e17", size = 75736 }, + { url = "https://files.pythonhosted.org/packages/c4/03/0f453a27877cb5a5fff16a975925f4119102cc8552f52536b9a98ef0431e/simplejson-3.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:71e849e7ceb2178344998cbe5ade101f1b329460243c79c27fbfc51c0447a7c3", size = 93109 }, + { url = "https://files.pythonhosted.org/packages/74/1f/a729f4026850cabeaff23e134646c3f455e86925d2533463420635ae54de/simplejson-3.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b63fdbab29dc3868d6f009a59797cefaba315fd43cd32ddd998ee1da28e50e29", size = 75475 }, + { url = "https://files.pythonhosted.org/packages/e2/14/50a2713fee8ff1f8d655b1a14f4a0f1c0c7246768a1b3b3d12964a4ed5aa/simplejson-3.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1190f9a3ce644fd50ec277ac4a98c0517f532cfebdcc4bd975c0979a9f05e1fb", size = 75112 }, + { url = "https://files.pythonhosted.org/packages/45/86/ea9835abb646755140e2d482edc9bc1e91997ed19a59fd77ae4c6a0facea/simplejson-3.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1336ba7bcb722ad487cd265701ff0583c0bb6de638364ca947bb84ecc0015d1", size = 150245 }, + { url = "https://files.pythonhosted.org/packages/12/b4/53084809faede45da829fe571c65fbda8479d2a5b9c633f46b74124d56f5/simplejson-3.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e975aac6a5acd8b510eba58d5591e10a03e3d16c1cf8a8624ca177491f7230f0", size = 158465 }, + { url = "https://files.pythonhosted.org/packages/a9/7d/d56579468d1660b3841e1f21c14490d103e33cf911886b22652d6e9683ec/simplejson-3.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a6dd11ee282937ad749da6f3b8d87952ad585b26e5edfa10da3ae2536c73078", size = 148514 }, + { url = "https://files.pythonhosted.org/packages/19/e3/874b1cca3d3897b486d3afdccc475eb3a09815bf1015b01cf7fcb52a55f0/simplejson-3.20.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab980fcc446ab87ea0879edad41a5c28f2d86020014eb035cf5161e8de4474c6", size = 152262 }, + { url = "https://files.pythonhosted.org/packages/32/84/f0fdb3625292d945c2bd13a814584603aebdb38cfbe5fe9be6b46fe598c4/simplejson-3.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f5aee2a4cb6b146bd17333ac623610f069f34e8f31d2f4f0c1a2186e50c594f0", size = 150164 }, + { url = "https://files.pythonhosted.org/packages/95/51/6d625247224f01eaaeabace9aec75ac5603a42f8ebcce02c486fbda8b428/simplejson-3.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:652d8eecbb9a3b6461b21ec7cf11fd0acbab144e45e600c817ecf18e4580b99e", size = 151795 }, + { url = "https://files.pythonhosted.org/packages/7f/d9/bb921df6b35be8412f519e58e86d1060fddf3ad401b783e4862e0a74c4c1/simplejson-3.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c09948f1a486a89251ee3a67c9f8c969b379f6ffff1a6064b41fea3bce0a112", size = 159027 }, + { url = "https://files.pythonhosted.org/packages/03/c5/5950605e4ad023a6621cf4c931b29fd3d2a9c1f36be937230bfc83d7271d/simplejson-3.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cbbd7b215ad4fc6f058b5dd4c26ee5c59f72e031dfda3ac183d7968a99e4ca3a", size = 154380 }, + { url = "https://files.pythonhosted.org/packages/66/ad/b74149557c5ec1e4e4d55758bda426f5d2ec0123cd01a53ae63b8de51fa3/simplejson-3.20.1-cp313-cp313-win32.whl", hash = "sha256:ae81e482476eaa088ef9d0120ae5345de924f23962c0c1e20abbdff597631f87", size = 74102 }, + { url = "https://files.pythonhosted.org/packages/db/a9/25282fdd24493e1022f30b7f5cdf804255c007218b2bfaa655bd7ad34b2d/simplejson-3.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b9fd15853b90aec3b1739f4471efbf1ac05066a2c7041bf8db821bb73cd2ddc", size = 75736 }, + { url = "https://files.pythonhosted.org/packages/4b/30/00f02a0a921556dd5a6db1ef2926a1bc7a8bbbfb1c49cfed68a275b8ab2b/simplejson-3.20.1-py3-none-any.whl", hash = "sha256:8a6c1bbac39fa4a79f83cbf1df6ccd8ff7069582a9fd8db1e52cea073bc2c697", size = 57121 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/35/7d8d94eb0474352d55f60f80ebc30f7e59441a29e18886a6425f0bccd0d3/sse_starlette-2.3.3.tar.gz", hash = "sha256:fdd47c254aad42907cfd5c5b83e2282be15be6c51197bf1a9b70b8e990522072", size = 17499 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/20/52fdb5ebb158294b0adb5662235dd396fc7e47aa31c293978d8d8942095a/sse_starlette-2.3.3-py3-none-any.whl", hash = "sha256:8b0a0ced04a329ff7341b01007580dd8cf71331cc21c0ccea677d500618da1e0", size = 10235 }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638 }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + +[[package]] +name = "typer" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, +]