#
tokens: 4283/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .github
│   └── workflows
│       └── publish.yml
├── .python-version
├── LICENSE
├── mcp_doris
│   ├── __init__.py
│   ├── __pycache__
│   │   ├── __init__.cpython-313.pyc
│   │   └── mcp_env.cpython-313.pyc
│   ├── main.py
│   ├── mcp_env.py
│   └── mcp_server.py
├── mcp-doris-demo.gif
├── pyproject.toml
├── README.md
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
1 | 3.13
2 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Apache Doris MCP Server
 2 | 
 3 | [![smithery badge](https://smithery.ai/badge/@morningman/mcp-doris)](https://smithery.ai/server/@morningman/mcp-doris)
 4 | 
 5 | An [MCP server](https://modelcontextprotocol.io/introduction) for [Apache Doris](https://doris.apache.org/).
 6 | 
 7 | ![Demo](mcp-doris-demo.gif)
 8 | 
 9 | ## Usage
10 | 
11 | ### Cursor
12 | 
13 | ```
14 | Name: doris
15 | Type: command
16 | Command: DORIS_HOST=<doris-host> DORIS_PORT=<port> DORIS_USER=<doris-user> DORIS_PASSWORD=<doris-pwd> uv run --with mcp-doris --python 3.13 mcp-doris
17 | ```
18 | 
19 | ## Development
20 | 
21 | ### Prerequest
22 | 
23 | - install [uv](https://docs.astral.sh/uv)
24 | 
25 | ### Run MCP Inspector
26 | 
27 | ```sql
28 | cd /path/to/mcp-doris
29 | uv sync
30 | source .venv/bin/activate
31 | export PYTHONPATH=/path/to/mcp-doris:$PYTHONPATH
32 | env DORIS_HOST=<doris-host> DORIS_PORT=<port> DORIS_USER=<doris-user> DORIS_PASSWORD=<doris-pwd> mcp dev mcp_doris/mcp_server.py
33 | ```
34 | 
35 | Then visit `http://localhost:5173` in web browser.
36 | 
37 | ## Publish
38 | 
39 | ```
40 | uv build
41 | uv publish
42 | ```
43 | 
```

--------------------------------------------------------------------------------
/mcp_doris/main.py:
--------------------------------------------------------------------------------

```python
1 | from .mcp_server import mcp
2 | 
3 | def main():
4 |     mcp.run()
5 | 
6 | if __name__ == "__main__":
7 |     main()
8 | 
```

--------------------------------------------------------------------------------
/mcp_doris/__init__.py:
--------------------------------------------------------------------------------

```python
 1 | from .mcp_server import (
 2 |     create_doris_client,
 3 |     show_databases,
 4 |     show_tables,
 5 |     execute_query,
 6 | )
 7 | 
 8 | __all__ = [
 9 |     "show_databases",
10 |     "show_tables",
11 |     "execute_query",
12 |     "create_doris_client",
13 | ] 
14 | 
```

--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------

```yaml
 1 | on:
 2 |   workflow_dispatch:
 3 | 
 4 | jobs:
 5 |   publish:
 6 |     name: Upload release to PyPI
 7 |     runs-on: ubuntu-latest
 8 |     environment:
 9 |       name: pypi
10 |       url: "https://pypi.org/p/mcp-doris"
11 |     permissions:
12 |       id-token: write
13 |     steps:
14 |       - uses: actions/checkout@v4
15 |       - uses: astral-sh/setup-uv@v5
16 |       - run: uv python install
17 |       - run: uv build
18 |       - uses: pypa/gh-action-pypi-publish@release/v1
19 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "mcp-doris"
 3 | version = "0.1.1"
 4 | description = "An MCP server for Apache Doris."
 5 | readme = "README.md"
 6 | license = "Apache-2.0"
 7 | license-files = ["LICENSE"]
 8 | requires-python = ">=3.13"
 9 | dependencies = [
10 |      "mcp[cli]>=1.3.0",
11 |      "python-dotenv>=1.0.1",
12 |      "uvicorn>=0.34.0",
13 |      "pip-system-certs>=4.0",
14 |      "mysql-connector-python>=9.2.0",
15 | ]
16 | 
17 | [project.scripts]
18 | mcp-doris = "mcp_doris.main:main"
19 | 
20 | [project.urls]
21 | Home = "https://github.com/morningman/mcp-doris"
22 | 
23 | [project.optional-dependencies]
24 | dev = [
25 |     "ruff",
26 |     "pytest"
27 | ]
28 | 
29 | [tool.hatch.build.targets.wheel]
30 | packages = ["mcp_doris"]
31 | 
32 | [tool.ruff]
33 | line-length = 100
34 | 
35 | [build-system]
36 | requires = ["hatchling"]
37 | build-backend = "hatchling.build"
38 | 
```

--------------------------------------------------------------------------------
/mcp_doris/mcp_env.py:
--------------------------------------------------------------------------------

```python
  1 | """Environment configuration for the MCP Doris server.
  2 | 
  3 | This module handles all environment variable configuration with sensible defaults
  4 | and type conversion.
  5 | """
  6 | 
  7 | from dataclasses import dataclass
  8 | import os
  9 | from typing import Optional
 10 | 
 11 | 
 12 | @dataclass
 13 | class DorisConfig:
 14 |     """Configuration for Apache Doris connection settings.
 15 | 
 16 |     This class handles all environment variable configuration with sensible defaults
 17 |     and type conversion. It provides typed methods for accessing each configuration value.
 18 | 
 19 |     Required environment variables:
 20 |         DORIS_HOST: The hostname of the Doris server
 21 |         DORIS_PORT: The port number (default: 9030)
 22 |         DORIS_USER: The username for authentication
 23 |         DORIS_PASSWORD: The password for authentication
 24 | 
 25 |     Optional environment variables (with defaults):
 26 |         DORIS_DATABASE: Default database to use (default: None)
 27 |         DORIS_CONNECT_TIMEOUT: Connection timeout in seconds (default: 30)
 28 |         DORIS_READ_TIMEOUT: Read timeout in seconds (default: 300)
 29 |     """
 30 | 
 31 |     def __init__(self):
 32 |         """Initialize the configuration from environment variables."""
 33 |         self._validate_required_vars()
 34 | 
 35 |     @property
 36 |     def host(self) -> str:
 37 |         """Get the Doris host."""
 38 |         return os.environ["DORIS_HOST"]
 39 | 
 40 |     @property
 41 |     def port(self) -> int:
 42 |         """Get the Doris port.
 43 | 
 44 |         Defaults to 9030 (Doris MySQL protocol port).
 45 |         Can be overridden by DORIS_PORT environment variable.
 46 |         """
 47 |         return int(os.getenv("DORIS_PORT", "9030"))
 48 | 
 49 |     @property
 50 |     def username(self) -> str:
 51 |         """Get the Doris username."""
 52 |         return os.environ["DORIS_USER"]
 53 | 
 54 |     @property
 55 |     def password(self) -> str:
 56 |         """Get the Doris password."""
 57 |         return os.environ["DORIS_PASSWORD"]
 58 | 
 59 |     @property
 60 |     def database(self) -> Optional[str]:
 61 |         """Get the default database name if set."""
 62 |         return os.getenv("DORIS_DATABASE")
 63 | 
 64 |     @property
 65 |     def connect_timeout(self) -> int:
 66 |         """Get the connection timeout in seconds.
 67 | 
 68 |         Default: 30
 69 |         """
 70 |         return int(os.getenv("DORIS_CONNECT_TIMEOUT", "30"))
 71 | 
 72 |     @property
 73 |     def read_timeout(self) -> int:
 74 |         """Get the read timeout in seconds.
 75 | 
 76 |         Default: 300
 77 |         """
 78 |         return int(os.getenv("DORIS_READ_TIMEOUT", "300"))
 79 | 
 80 |     def get_client_config(self) -> dict:
 81 |         """Get the configuration dictionary for MySQL client.
 82 | 
 83 |         Returns:
 84 |             dict: Configuration ready to be passed to mysql.connector.connect()
 85 |         """
 86 |         config = {
 87 |             "host": self.host,
 88 |             "port": self.port,
 89 |             "user": self.username,
 90 |             "password": self.password,
 91 |             "connect_timeout": self.connect_timeout,
 92 |             "connection_timeout": self.read_timeout,
 93 |         }
 94 | 
 95 |         # Add optional database if set
 96 |         if self.database:
 97 |             config["database"] = self.database
 98 | 
 99 |         return config
100 | 
101 |     def _validate_required_vars(self) -> None:
102 |         """Validate that all required environment variables are set.
103 | 
104 |         Raises:
105 |             ValueError: If any required environment variable is missing.
106 |         """
107 |         missing_vars = []
108 |         for var in ["DORIS_HOST", "DORIS_PORT", "DORIS_USER", "DORIS_PASSWORD"]:
109 |             if var not in os.environ:
110 |                 missing_vars.append(var)
111 | 
112 |         if missing_vars:
113 |             raise ValueError(
114 |                 f"Missing required environment variables: {', '.join(missing_vars)}"
115 |             )
116 | 
117 | 
118 | # Global instance for easy access
119 | config = DorisConfig() 
120 | 
```

--------------------------------------------------------------------------------
/mcp_doris/mcp_server.py:
--------------------------------------------------------------------------------

```python
  1 | import logging
  2 | from typing import List, Dict, Any
  3 | import concurrent.futures
  4 | import atexit
  5 | import mysql.connector
  6 | from mysql.connector import errorcode
  7 | 
  8 | from dotenv import load_dotenv
  9 | from mcp.server.fastmcp import FastMCP
 10 | 
 11 | from mcp_doris.mcp_env import config
 12 | 
 13 | MCP_SERVER_NAME = "mcp-doris"
 14 | 
 15 | # Configure logging
 16 | logging.basicConfig(
 17 |     level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
 18 | )
 19 | logger = logging.getLogger(MCP_SERVER_NAME)
 20 | 
 21 | QUERY_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10)
 22 | atexit.register(lambda: QUERY_EXECUTOR.shutdown(wait=True))
 23 | SELECT_QUERY_TIMEOUT_SECS = 30
 24 | 
 25 | load_dotenv()
 26 | 
 27 | deps = [
 28 |     "mysql-connector-python",
 29 |     "python-dotenv",
 30 |     "uvicorn",
 31 |     "pip-system-certs",
 32 | ]
 33 | 
 34 | mcp = FastMCP(MCP_SERVER_NAME, dependencies=deps)
 35 | 
 36 | 
 37 | def create_doris_client():
 38 |     """Create a MySQL connection to Apache Doris.
 39 |     
 40 |     Returns:
 41 |         mysql.connector.connection.MySQLConnection: A connection to the Doris database
 42 |     """
 43 |     client_config = config.get_client_config()
 44 |     logger.info(
 45 |         f"Creating Doris client connection to {client_config['host']}:{client_config['port']} "
 46 |         f"as {client_config['user']} "
 47 |         f"(connect_timeout={client_config['connect_timeout']}s, "
 48 |         f"connection_timeout={client_config['connection_timeout']}s)"
 49 |     )
 50 | 
 51 |     try:
 52 |         conn = mysql.connector.connect(**client_config)
 53 |         # Test the connection
 54 |         cursor = conn.cursor()
 55 |         cursor.execute("SELECT VERSION()")
 56 |         version = cursor.fetchone()[0]
 57 |         cursor.close()
 58 |         logger.info(f"Successfully connected to Apache Doris version {version}")
 59 |         return conn
 60 |     except mysql.connector.Error as err:
 61 |         if err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
 62 |             logger.error("Invalid username or password")
 63 |         elif err.errno == errorcode.ER_BAD_DB_ERROR:
 64 |             logger.error("Database does not exist")
 65 |         else:
 66 |             logger.error(f"Failed to connect to Doris: {err}")
 67 |         raise
 68 | 
 69 | 
 70 | @mcp.tool()
 71 | def show_databases():
 72 |     """List all databases in the Doris instance.
 73 |     
 74 |     Returns:
 75 |         List[str]: A list of database names
 76 |     """
 77 |     logger.info("Listing all databases")
 78 |     conn = create_doris_client()
 79 |     cursor = conn.cursor()
 80 |     
 81 |     try:
 82 |         cursor.execute("SHOW DATABASES")
 83 |         databases = [row[0] for row in cursor.fetchall()]
 84 |         logger.info(f"Found {len(databases)} databases")
 85 |         return databases
 86 |     finally:
 87 |         cursor.close()
 88 |         conn.close()
 89 | 
 90 | 
 91 | @mcp.tool()
 92 | def show_tables(database: str, like: str = None):
 93 |     """List all tables in the specified database.
 94 |     
 95 |     Args:
 96 |         database: The database name
 97 |         like: Optional pattern to filter table names
 98 |         
 99 |     Returns:
100 |         List[Dict]: A list of table information dictionaries
101 |     """
102 |     logger.info(f"Listing tables in database '{database}'")
103 |     conn = create_doris_client()
104 |     cursor = conn.cursor(dictionary=True)
105 |     
106 |     try:
107 |         # Use the specified database
108 |         cursor.execute(f"USE `{database}`")
109 |         
110 |         # Get tables
111 |         query = "SHOW TABLES"
112 |         if like:
113 |             query += f" LIKE '{like}'"
114 |         cursor.execute(query)
115 |         table_names = [row['Tables_in_' + database] for row in cursor.fetchall()]
116 |         
117 |         tables = []
118 |         for table in table_names:
119 |             logger.info(f"Getting schema info for table {database}.{table}")
120 |             
121 |             # Get table schema
122 |             cursor.execute(f"DESCRIBE `{table}`")
123 |             columns = cursor.fetchall()
124 |             
125 |             # Get create table statement
126 |             cursor.execute(f"SHOW CREATE TABLE `{table}`")
127 |             create_table_result = cursor.fetchone()['Create Table']
128 |             
129 |             # Get table comment if available (extracted from create table statement)
130 |             table_comment = None
131 |             if "COMMENT=" in create_table_result:
132 |                 comment_parts = create_table_result.split("COMMENT=")
133 |                 if len(comment_parts) > 1:
134 |                     table_comment = comment_parts[1].split("'")[1]
135 |             
136 |             tables.append({
137 |                 "database": database,
138 |                 "name": table,
139 |                 "comment": table_comment,
140 |                 "columns": columns,
141 |                 "create_table_query": create_table_result,
142 |             })
143 |         
144 |         logger.info(f"Found {len(tables)} tables")
145 |         return tables
146 |     finally:
147 |         cursor.close()
148 |         conn.close()
149 | 
150 | 
151 | def execute_query_impl(query: str) -> List[Dict[str, Any]]:
152 |     """Execute a SELECT query against Doris.
153 |     
154 |     Args:
155 |         query: The SQL query to execute
156 |         
157 |     Returns:
158 |         List[Dict]: The query results as a list of dictionaries
159 |     """
160 |     conn = create_doris_client()
161 |     cursor = conn.cursor(dictionary=True)
162 |     
163 |     try:
164 |         cursor.execute(query)
165 |         rows = cursor.fetchall()
166 |         logger.info(f"Query returned {len(rows)} rows")
167 |         return rows
168 |     except Exception as err:
169 |         logger.error(f"Error executing query: {err}")
170 |         return [{"error": f"Error running query: {err}"}]
171 |     finally:
172 |         cursor.close()
173 |         conn.close()
174 | 
175 | 
176 | @mcp.tool()
177 | def execute_query(query: str):
178 |     """Run a SELECT query against Doris with timeout protection.
179 |     
180 |     Args:
181 |         query: The SQL query to execute
182 |         
183 |     Returns:
184 |         List[Dict]: The query results
185 |     """
186 |     logger.info(f"Executing SELECT query: {query}")
187 |     
188 |     # Basic validation to ensure it's a SELECT query
189 |     if not query.strip().upper().startswith("SELECT"):
190 |         return {"error": "Only SELECT queries are allowed for security reasons"}
191 |     
192 |     future = QUERY_EXECUTOR.submit(execute_query_impl, query)
193 |     try:
194 |         result = future.result(timeout=SELECT_QUERY_TIMEOUT_SECS)
195 |         return result
196 |     except concurrent.futures.TimeoutError:
197 |         logger.warning(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}")
198 |         future.cancel()
199 |         return {"error": f"Queries taking longer than {SELECT_QUERY_TIMEOUT_SECS} seconds are currently not supported."} 
200 | 
```