# 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 | [](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 | 
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 |
```