Skip to content

Commit 196d207

Browse files
Jove Zhongiskakaushikserprexmshustovkapustor
authored
Port a1bdc6 to1dc0c4 (#11)
* feat: Add query timeout and thread pool for SELECT queries (ClickHouse#20) * add action to publish to pypi (ClickHouse#19) * add client_name with mcp_clickhouse (ClickHouse#21) * Add descriptions for tools required by Bedrock (ClickHouse#23) Addresses ClickHouse#22 * 0.1.5 * fix: prevent BrokenResourceError by returning structured responses for query errors (ClickHouse#26) fixes ClickHouse#25 * Update README.md - Added link to the Youtube overview (ClickHouse#27) * fix cherry pick issue and add desc * Update publish.yml * change mcp client name --------- Co-authored-by: Kaushik Iska <iska.kaushik@gmail.com> Co-authored-by: Philip Dubé <serprex@users.noreply.github.com> Co-authored-by: Mikhail Shustov <restrry@gmail.com> Co-authored-by: Dmitry Pavlov <pavlovdmst@gmail.com> Co-authored-by: Philip Dubé <philip.dube@clickhouse.com> Co-authored-by: Ryadh DAHIMENE <dahimene.ryadh@gmail.com>
1 parent 08d65df commit 196d207

File tree

5 files changed

+63
-8
lines changed

5 files changed

+63
-8
lines changed

.github/workflows/publish.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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-timeplus"
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

mcp_timeplus/mcp_env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def get_client_config(self) -> dict:
121121
"verify": self.verify,
122122
"connect_timeout": self.connect_timeout,
123123
"send_receive_timeout": self.send_receive_timeout,
124+
"client_name": "mcp_timeplus",
124125
}
125126

126127
# Add optional database if set

mcp_timeplus/mcp_server.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
22
from typing import Sequence
3+
import concurrent.futures
4+
import atexit
35

46
import timeplus_connect
57
from timeplus_connect.driver.binding import quote_identifier, format_query_value
@@ -20,6 +22,10 @@
2022
)
2123
logger = logging.getLogger(MCP_SERVER_NAME)
2224

25+
QUERY_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10)
26+
atexit.register(lambda: QUERY_EXECUTOR.shutdown(wait=True))
27+
SELECT_QUERY_TIMEOUT_SECS = 30
28+
2329
load_dotenv()
2430

2531
deps = [
@@ -35,6 +41,7 @@
3541

3642
@mcp.tool()
3743
def list_databases():
44+
"""List available Timeplus databases"""
3845
logger.info("Listing all databases")
3946
client = create_timeplus_client()
4047
result = client.command("SHOW DATABASES")
@@ -44,6 +51,7 @@ def list_databases():
4451

4552
@mcp.tool()
4653
def list_tables(database: str = 'default', like: str = None):
54+
"""List available tables/streams in the given database"""
4755
logger.info(f"Listing tables in database '{database}'")
4856
client = create_timeplus_client()
4957
query = f"SHOW STREAMS FROM {quote_identifier(database)}"
@@ -109,10 +117,7 @@ def get_table_info(table):
109117
logger.info(f"Found {len(tables)} tables")
110118
return tables
111119

112-
113-
@mcp.tool()
114-
def run_sql(query: str):
115-
logger.info(f"Executing query: {query}")
120+
def execute_query(query: str):
116121
client = create_timeplus_client()
117122
try:
118123
readonly = 1 if config.readonly else 0
@@ -128,7 +133,36 @@ def run_sql(query: str):
128133
return rows
129134
except Exception as err:
130135
logger.error(f"Error executing query: {err}")
131-
return f"error running query: {err}"
136+
# Return a structured dictionary rather than a string to ensure proper serialization
137+
# by the MCP protocol. String responses for errors can cause BrokenResourceError.
138+
return {"error": str(err)}
139+
140+
@mcp.tool()
141+
def run_sql(query: str):
142+
"""Run a query in a Timeplus database"""
143+
logger.info(f"Executing query: {query}")
144+
try:
145+
future = QUERY_EXECUTOR.submit(execute_query, query)
146+
try:
147+
result = future.result(timeout=SELECT_QUERY_TIMEOUT_SECS)
148+
# Check if we received an error structure from execute_query
149+
if isinstance(result, dict) and "error" in result:
150+
logger.warning(f"Query failed: {result['error']}")
151+
# MCP requires structured responses; string error messages can cause
152+
# serialization issues leading to BrokenResourceError
153+
return {"status": "error", "message": f"Query failed: {result['error']}"}
154+
return result
155+
except concurrent.futures.TimeoutError:
156+
logger.warning(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}")
157+
future.cancel()
158+
# Return a properly structured response for timeout errors
159+
return {"status": "error", "message": f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds"}
160+
except Exception as e:
161+
logger.error(f"Unexpected error in run_select_query: {str(e)}")
162+
# Catch all other exceptions and return them in a structured format
163+
# to prevent MCP serialization failures
164+
return {"status": "error", "message": f"Unexpected error: {str(e)}"}
165+
132166

133167
@mcp.prompt()
134168
def generate_sql(requirements: str) -> str:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-timeplus"
3-
version = "0.1.4"
3+
version = "0.1.5"
44
description = "An MCP server for Timeplus."
55
readme = "README.md"
66
license = "Apache-2.0"

tests/test_tool.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ def test_run_select_query_failure(self):
7070
"""Test running a SELECT query with an error."""
7171
query = f"SELECT * FROM {self.test_db}.non_existent_table"
7272
result = run_sql(query)
73-
self.assertIsInstance(result, str)
74-
self.assertIn("error running query", result)
73+
self.assertIsInstance(result, dict)
74+
self.assertEqual(result["status"], "error")
75+
self.assertIn("Query failed", result["message"])
76+
7577

7678
def test_table_and_column_comments(self):
7779
"""Test that table and column comments are correctly retrieved."""

0 commit comments

Comments
 (0)