Skip to content

Commit cbb05a1

Browse files
rfrneo4ja-s-g93
andauthored
cypher - read query timeout implementation (#163)
* read query timeout implementation * update artifacts for read_timeout * add logging, update tests, update manifest * remove timeout from schema tool, update readme and changelog --------- Co-authored-by: runfourestrun <[email protected]> Co-authored-by: alex <[email protected]>
1 parent f86ce73 commit cbb05a1

File tree

9 files changed

+242
-8
lines changed

9 files changed

+242
-8
lines changed

servers/mcp-neo4j-cypher/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
### Added
99
* Added Cypher result sanitation function from Neo4j GraphRAG that removes embedding values from the result
10+
* Added `read_neo4j_cypher` query timeout configuration via `--read-timeout` CLI parameter and `NEO4J_READ_TIMEOUT` environment variable (defaults to 30 seconds)
1011
* Add response token limit for read Cypher responses
1112

1213
## v0.3.1

servers/mcp-neo4j-cypher/README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The server offers these core tools:
3030
- `query` (string): The Cypher query to execute
3131
- `params` (dictionary, optional): Parameters to pass to the Cypher query
3232
- Returns: Query results as JSON serialized array of objects
33+
- **Timeout**: Read queries are subject to a configurable timeout (default: 30 seconds) to prevent long-running queries from disrupting conversational flow
3334

3435
- `write_neo4j_cypher`
3536
- Execute updating Cypher queries
@@ -55,6 +56,27 @@ This is useful when you need to connect to multiple Neo4j databases or instances
5556

5657
The server provides configuration options to optimize query performance and manage response sizes:
5758

59+
#### ⏱️ Query Timeouts
60+
61+
Configure timeouts for read queries to prevent long-running queries from disrupting conversational flow:
62+
63+
**Command Line:**
64+
```bash
65+
mcp-neo4j-cypher --read-timeout 60 # 60 seconds
66+
```
67+
68+
**Environment Variable:**
69+
```bash
70+
export NEO4J_READ_TIMEOUT=60
71+
```
72+
73+
**Docker:**
74+
```bash
75+
docker run -e NEO4J_READ_TIMEOUT=60 mcp-neo4j-cypher:latest
76+
```
77+
78+
**Default**: 30 seconds. Read queries that exceed this timeout will be automatically cancelled to maintain responsive interactions with AI models.
79+
5880
#### 📏 Token Limits
5981

6082
Control the maximum size of query responses to prevent overwhelming the AI model:
@@ -198,7 +220,7 @@ In this setup:
198220
- The movies database tools will be prefixed with `movies-` (e.g., `movies-read_neo4j_cypher`)
199221
- The local database tools will be prefixed with `local-` (e.g., `local-get_neo4j_schema`)
200222

201-
Syntax with `--db-url`, `--username`, `--password` and other command line arguments is still supported but environment variables are preferred:
223+
Syntax with `--db-url`, `--username`, `--password`, `--read-timeout` and other command line arguments is still supported but environment variables are preferred:
202224

203225
<details>
204226
<summary>Legacy Syntax</summary>
@@ -304,6 +326,7 @@ docker run --rm -p 8000:8000 \
304326
| `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport |
305327
| `NEO4J_MCP_SERVER_PATH` | `/api/mcp/` | Path for accessing MCP server |
306328
| `NEO4J_RESPONSE_TOKEN_LIMIT` | _(none)_ | Maximum tokens for read query responses |
329+
| `NEO4J_READ_TIMEOUT` | `30` | Timeout in seconds for read queries |
307330

308331
### 🌐 SSE Transport for Legacy Web Access
309332

servers/mcp-neo4j-cypher/manifest.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"NEO4J_MCP_SERVER_HOST": "${user_config.mcp_server_host}",
3333
"NEO4J_MCP_SERVER_PORT": "${user_config.mcp_server_port}",
3434
"NEO4J_MCP_SERVER_PATH": "${user_config.mcp_server_path}",
35-
"NEO4J_RESPONSE_TOKEN_LIMIT": "${user_config.token_limit}"
35+
"NEO4J_RESPONSE_TOKEN_LIMIT": "${user_config.token_limit}",
36+
"NEO4J_READ_TIMEOUT": "${user_config.read_timeout}"
3637
}
3738
}
3839
},
@@ -127,12 +128,20 @@
127128
"sensitive": false
128129
},
129130
"token_limit": {
130-
"type": "int",
131+
"type": "number",
131132
"title": "Response token limit",
132133
"description": "Optional response token limit for the read tool.",
133134
"default": "",
134135
"required": false,
135136
"sensitive": false
137+
},
138+
"read_timeout": {
139+
"type": "number",
140+
"title": "Read timeout",
141+
"description": "Optional read timeout in seconds for read queries.",
142+
"default": 30,
143+
"required": false,
144+
"sensitive": false
136145
}
137146
}
138147
}

servers/mcp-neo4j-cypher/src/mcp_neo4j_cypher/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def main():
2121
)
2222
parser.add_argument("--server-host", default=None, help="Server host")
2323
parser.add_argument("--server-port", default=None, help="Server port")
24+
parser.add_argument(
25+
"--read-timeout",
26+
type=int,
27+
default=None,
28+
help="Timeout in seconds for read queries (default: 30)"
29+
)
2430
parser.add_argument("--token-limit", default=None, help="Response token limit")
2531

2632
args = parser.parse_args()

servers/mcp-neo4j-cypher/src/mcp_neo4j_cypher/server.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastmcp.server import FastMCP
88
from fastmcp.tools.tool import TextContent, ToolResult
99
from mcp.types import ToolAnnotations
10-
from neo4j import AsyncDriver, AsyncGraphDatabase, RoutingControl
10+
from neo4j import AsyncDriver, AsyncGraphDatabase, RoutingControl, Query
1111
from neo4j.exceptions import ClientError, Neo4jError
1212
from pydantic import Field
1313
from .utils import _value_sanitize, _truncate_string_to_tokens
@@ -34,9 +34,10 @@ def _is_write_query(query: str) -> bool:
3434

3535

3636
def create_mcp_server(
37-
neo4j_driver: AsyncDriver,
38-
database: str = "neo4j",
37+
neo4j_driver: AsyncDriver,
38+
database: str = "neo4j",
3939
namespace: str = "",
40+
read_timeout: int = 30,
4041
token_limit: Optional[int] = None,
4142
) -> FastMCP:
4243
mcp: FastMCP = FastMCP(
@@ -177,8 +178,9 @@ async def read_neo4j_cypher(
177178
raise ValueError("Only MATCH queries are allowed for read-query")
178179

179180
try:
181+
query_obj = Query(query, timeout=float(read_timeout))
180182
results = await neo4j_driver.execute_query(
181-
query,
183+
query_obj,
182184
parameters_=params,
183185
routing_control=RoutingControl.READ,
184186
database_=database,
@@ -261,6 +263,7 @@ async def main(
261263
host: str = "127.0.0.1",
262264
port: int = 8000,
263265
path: str = "/mcp/",
266+
read_timeout: int = 30,
264267
token_limit: Optional[int] = None,
265268
) -> None:
266269
logger.info("Starting MCP neo4j Server")
@@ -273,7 +276,7 @@ async def main(
273276
),
274277
)
275278

276-
mcp = create_mcp_server(neo4j_driver, database, namespace, token_limit)
279+
mcp = create_mcp_server(neo4j_driver, database, namespace, read_timeout, token_limit)
277280

278281
# Run the server with the specified transport
279282
match transport:

servers/mcp-neo4j-cypher/src/mcp_neo4j_cypher/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,38 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
169169
"Info: No server path provided and transport is `stdio`. `server_path` will be None."
170170
)
171171
config["path"] = None
172+
172173
# parse token limit
173174
if args.token_limit is not None:
174175
config["token_limit"] = args.token_limit
175176
else:
176177
if os.getenv("NEO4J_RESPONSE_TOKEN_LIMIT") is not None:
177178
config["token_limit"] = int(os.getenv("NEO4J_RESPONSE_TOKEN_LIMIT"))
179+
logger.info(
180+
f"Info: Cypher read query token limit provided. Using provided value: {config['token_limit']} tokens"
181+
)
178182
else:
179183
logger.info("Info: No token limit provided. No token limit will be used.")
180184
config["token_limit"] = None
181185

186+
# parse read timeout
187+
if args.read_timeout is not None:
188+
config["read_timeout"] = args.read_timeout
189+
else:
190+
if os.getenv("NEO4J_READ_TIMEOUT") is not None:
191+
try:
192+
config["read_timeout"] = int(os.getenv("NEO4J_READ_TIMEOUT"))
193+
logger.info(
194+
f"Info: Cypher read query timeout provided. Using provided value: {config['read_timeout']} seconds"
195+
)
196+
config["read_timeout"] = config["read_timeout"]
197+
except ValueError:
198+
logger.warning("Warning: Invalid read timeout provided. Using default: 30 seconds")
199+
config["read_timeout"] = 30
200+
else:
201+
logger.info("Info: No read timeout provided. Using default: 30 seconds")
202+
config["read_timeout"] = 30
203+
182204
return config
183205

184206

servers/mcp-neo4j-cypher/tests/integration/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ async def mcp_server(async_neo4j_driver):
5353
return mcp
5454

5555

56+
@pytest_asyncio.fixture(scope="function")
57+
async def mcp_server_short_timeout(async_neo4j_driver):
58+
"""MCP server with a very short timeout for testing timeout behavior."""
59+
mcp = create_mcp_server(async_neo4j_driver, "neo4j", read_timeout=0.01)
60+
61+
return mcp
62+
63+
5664
@pytest.fixture(scope="function")
5765
def init_data(setup: Neo4jContainer, clear_data: Any):
5866
with setup.get_driver().session(database="neo4j") as session:

servers/mcp-neo4j-cypher/tests/integration/test_server_tools_IT.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import json
22
from typing import Any
3+
from unittest.mock import patch
34

45
import pytest
6+
from fastmcp.exceptions import ToolError
57
from fastmcp.server import FastMCP
8+
from neo4j.exceptions import Neo4jError
69

710

811
@pytest.mark.asyncio(loop_scope="function")
@@ -53,3 +56,100 @@ async def test_read_neo4j_cypher(mcp_server: FastMCP, init_data: Any):
5356
assert result[0]["friend_name"] == "Bob"
5457
assert result[1]["person"] == "Bob"
5558
assert result[1]["friend_name"] == "Charlie"
59+
60+
61+
@pytest.mark.asyncio(loop_scope="function")
62+
async def test_read_query_timeout_with_slow_query(mcp_server_short_timeout: FastMCP, clear_data: Any):
63+
"""Test that read queries timeout appropriately with a slow query."""
64+
# Create a query that should take longer than 0.01 seconds
65+
slow_query = """
66+
WITH range(1, 10000) AS r
67+
UNWIND r AS x
68+
WITH x
69+
WHERE x % 2 = 0
70+
RETURN count(x) AS result
71+
"""
72+
73+
tool = await mcp_server_short_timeout.get_tool("read_neo4j_cypher")
74+
75+
# The query might timeout and raise a ToolError, or it might complete very fast
76+
# Let's just verify the server handles it without crashing
77+
try:
78+
response = await tool.run(dict(query=slow_query))
79+
# If it completes, verify it returns valid results
80+
if response.content[0].text:
81+
result = json.loads(response.content[0].text)
82+
assert isinstance(result, list)
83+
except ToolError as e:
84+
# If it times out, that's also acceptable behavior
85+
error_message = str(e)
86+
assert "Neo4j Error" in error_message
87+
88+
89+
@pytest.mark.asyncio(loop_scope="function")
90+
async def test_read_query_with_normal_timeout_succeeds(mcp_server: FastMCP, init_data: Any):
91+
"""Test that normal queries succeed with reasonable timeout."""
92+
query = "MATCH (p:Person) RETURN p.name AS name ORDER BY name"
93+
94+
tool = await mcp_server.get_tool("read_neo4j_cypher")
95+
response = await tool.run(dict(query=query))
96+
97+
result = json.loads(response.content[0].text)
98+
99+
# Should succeed and return expected results
100+
assert len(result) == 3
101+
assert result[0]["name"] == "Alice"
102+
assert result[1]["name"] == "Bob"
103+
assert result[2]["name"] == "Charlie"
104+
105+
106+
@pytest.mark.asyncio(loop_scope="function")
107+
async def test_schema_query_timeout(mcp_server_short_timeout: FastMCP):
108+
"""Test that schema queries also respect timeout settings."""
109+
tool = await mcp_server_short_timeout.get_tool("get_neo4j_schema")
110+
111+
# Schema query should typically be fast, but with very short timeout it might timeout
112+
# depending on the database state. Let's just verify it doesn't crash
113+
try:
114+
response = await tool.run(dict())
115+
# If it succeeds, verify the response format
116+
if response.content[0].text:
117+
schema = json.loads(response.content[0].text)
118+
assert isinstance(schema, dict)
119+
except ToolError as e:
120+
# If it times out, that's also acceptable behavior for this test
121+
error_message = str(e)
122+
assert "Neo4j Error" in error_message or "timeout" in error_message.lower()
123+
124+
125+
@pytest.mark.asyncio(loop_scope="function")
126+
async def test_write_query_no_timeout(mcp_server_short_timeout: FastMCP, clear_data: Any):
127+
"""Test that write queries are not subject to timeout restrictions."""
128+
# Write queries should not be affected by read_timeout
129+
query = "CREATE (n:TimeoutTest {name: 'test', created: timestamp()}) RETURN n.name"
130+
131+
tool = await mcp_server_short_timeout.get_tool("write_neo4j_cypher")
132+
response = await tool.run(dict(query=query))
133+
134+
result = json.loads(response.content[0].text)
135+
136+
# Write operation should succeed regardless of short timeout
137+
assert "nodes_created" in result
138+
assert result["nodes_created"] == 1
139+
140+
141+
@pytest.mark.asyncio(loop_scope="function")
142+
async def test_timeout_configuration_passed_correctly(async_neo4j_driver):
143+
"""Test that timeout configuration is properly passed to the server."""
144+
from mcp_neo4j_cypher.server import create_mcp_server
145+
146+
# Create servers with different timeout values
147+
mcp_30s = create_mcp_server(async_neo4j_driver, "neo4j", read_timeout=30)
148+
mcp_60s = create_mcp_server(async_neo4j_driver, "neo4j", read_timeout=60)
149+
150+
# Both should be created successfully (configuration test)
151+
assert mcp_30s is not None
152+
assert mcp_60s is not None
153+
154+
# The actual timeout values are used internally in Query objects,
155+
# so this test mainly verifies the parameter is accepted without error

0 commit comments

Comments
 (0)