Skip to content

Commit 95207cd

Browse files
tomasonjoa-s-g93
andauthored
cypher - add read only config (#184)
* Add neo4j read only config to mcp cypher * fix * action store true * docs * update changelog, add unit and it for write tool availability, update config parsing logic for read only * fix unit tests, update readme * formatting --------- Co-authored-by: alex <[email protected]>
1 parent a73e190 commit 95207cd

File tree

11 files changed

+465
-109
lines changed

11 files changed

+465
-109
lines changed

servers/mcp-neo4j-cypher/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Changed
66

77
### Added
8+
* Add env variable `NEO4J_READ_ONLY` and cli variable `--read-only` to configure tool availability
89

910
## v0.4.0
1011

servers/mcp-neo4j-cypher/Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
format:
2+
uv run ruff check --select I . --fix
3+
uv run ruff check --fix .
4+
uv run ruff format .
5+
16
install-dev:
27
uv sync
38

servers/mcp-neo4j-cypher/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The server offers these core tools:
3838
- `query` (string): The Cypher update query
3939
- `params` (dictionary, optional): Parameters to pass to the Cypher query
4040
- Returns: A JSON serialized result summary counter with `{ nodes_updated: number, relationships_created: number, ... }`
41+
- **Availability**: May be disabled by supplying --read-only as cli flag or `NEO4J_READ_ONLY=true` environment variable
4142

4243
#### 🕸️ Schema Tools
4344

@@ -405,6 +406,7 @@ docker run --rm -p 8000:8000 \
405406
| `NEO4J_MCP_SERVER_ALLOWED_HOSTS` | `localhost,127.0.0.1` | Comma-separated list of allowed hosts (DNS rebinding protection) |
406407
| `NEO4J_RESPONSE_TOKEN_LIMIT` | _(none)_ | Maximum tokens for read query responses |
407408
| `NEO4J_READ_TIMEOUT` | `30` | Timeout in seconds for read queries |
409+
| `NEO4J_READ_ONLY` | `false` | Allow only read-only queries (true/false) |
408410

409411
### 🌐 SSE Transport for Legacy Web Access
410412

servers/mcp-neo4j-cypher/manifest.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,14 @@
160160
"default": 30,
161161
"required": false,
162162
"sensitive": false
163+
},
164+
"read_only": {
165+
"type": "boolean",
166+
"title": "Read Only Mode",
167+
"description": "Allow only read-only queries, no write operations permitted.",
168+
"default": false,
169+
"required": false,
170+
"sensitive": false
163171
}
164172
}
165173
}

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ def main():
3232
help="Allowed hosts for DNS rebinding protection on remote servers(comma-separated list)",
3333
)
3434
parser.add_argument(
35-
"--read-timeout",
36-
type=int,
37-
default=None,
38-
help="Timeout in seconds for read queries (default: 30)"
35+
"--read-timeout",
36+
type=int,
37+
default=None,
38+
help="Timeout in seconds for read queries (default: 30)",
39+
)
40+
parser.add_argument(
41+
"--read-only",
42+
action="store_true",
43+
help="Allow only read-only queries (default: False)",
3944
)
4045
parser.add_argument("--token-limit", default=None, help="Response token limit")
4146

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
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, Query
10+
from neo4j import AsyncDriver, AsyncGraphDatabase, Query, RoutingControl
1111
from neo4j.exceptions import ClientError, Neo4jError
1212
from pydantic import Field
1313
from starlette.middleware import Middleware
1414
from starlette.middleware.cors import CORSMiddleware
1515
from starlette.middleware.trustedhost import TrustedHostMiddleware
1616

17-
from .utils import _value_sanitize
18-
from .utils import _value_sanitize, _truncate_string_to_tokens
17+
from .utils import _truncate_string_to_tokens, _value_sanitize
1918

2019
logger = logging.getLogger("mcp_neo4j_cypher")
2120

@@ -39,17 +38,19 @@ def _is_write_query(query: str) -> bool:
3938

4039

4140
def create_mcp_server(
42-
neo4j_driver: AsyncDriver,
43-
database: str = "neo4j",
41+
neo4j_driver: AsyncDriver,
42+
database: str = "neo4j",
4443
namespace: str = "",
4544
read_timeout: int = 30,
4645
token_limit: Optional[int] = None,
46+
read_only: bool = False,
4747
) -> FastMCP:
4848
mcp: FastMCP = FastMCP(
4949
"mcp-neo4j-cypher", dependencies=["neo4j", "pydantic"], stateless_http=True
5050
)
5151

5252
namespace_prefix = _format_namespace(namespace)
53+
allow_writes = not read_only
5354

5455
@mcp.tool(
5556
name=namespace_prefix + "get_neo4j_schema",
@@ -219,6 +220,7 @@ async def read_neo4j_cypher(
219220
idempotentHint=False,
220221
openWorldHint=True,
221222
),
223+
enabled=allow_writes,
222224
)
223225
async def write_neo4j_cypher(
224226
query: str = Field(..., description="The Cypher query to execute."),
@@ -272,6 +274,7 @@ async def main(
272274
allowed_hosts: list[str] = [],
273275
read_timeout: int = 30,
274276
token_limit: Optional[int] = None,
277+
read_only: bool = False,
275278
) -> None:
276279
logger.info("Starting MCP neo4j Server")
277280

@@ -289,11 +292,12 @@ async def main(
289292
allow_methods=["GET", "POST"],
290293
allow_headers=["*"],
291294
),
292-
Middleware(TrustedHostMiddleware,
293-
allowed_hosts=allowed_hosts)
295+
Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts),
294296
]
295-
296-
mcp = create_mcp_server(neo4j_driver, database, namespace, read_timeout, token_limit)
297+
298+
mcp = create_mcp_server(
299+
neo4j_driver, database, namespace, read_timeout, token_limit, read_only
300+
)
297301

298302
# Run the server with the specified transport
299303
match transport:
@@ -311,7 +315,13 @@ async def main(
311315
logger.info(
312316
f"Running Neo4j Cypher MCP Server with SSE transport on {host}:{port}..."
313317
)
314-
await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse")
318+
await mcp.run_http_async(
319+
host=host,
320+
port=port,
321+
path=path,
322+
middleware=custom_middleware,
323+
transport="sse",
324+
)
315325
case _:
316326
logger.error(
317327
f"Invalid transport: {transport} | Must be either 'stdio', 'sse', or 'http'"

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

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,47 @@
1-
import tiktoken
2-
31
import argparse
42
import logging
53
import os
64
from typing import Any, Union
75

6+
import tiktoken
7+
88
logger = logging.getLogger("mcp_neo4j_cypher")
99
logger.setLevel(logging.INFO)
1010

1111

12+
def parse_boolean_safely(value: Union[str, bool]) -> bool:
13+
"""
14+
Safely parse a string value to boolean with strict validation.
15+
16+
Parameters
17+
----------
18+
value : Union[str, bool]
19+
The value to parse to boolean.
20+
21+
Returns
22+
-------
23+
bool
24+
The parsed boolean value.
25+
"""
26+
27+
if isinstance(value, bool):
28+
return value
29+
30+
elif isinstance(value, str):
31+
normalized = value.strip().lower()
32+
if normalized == "true":
33+
return True
34+
elif normalized == "false":
35+
return False
36+
else:
37+
raise ValueError(
38+
f"Invalid boolean value: '{value}'. Must be 'true' or 'false'"
39+
)
40+
# we shouldn't get here, but just in case
41+
else:
42+
raise ValueError(f"Invalid boolean value: '{value}'. Must be 'true' or 'false'")
43+
44+
1245
def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]:
1346
"""
1447
Process the command line arguments and environment variables to create a config dictionary.
@@ -173,14 +206,17 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
173206
# parse allow origins
174207
if args.allow_origins is not None:
175208
# Handle comma-separated string from CLI
176-
177-
config["allow_origins"] = [origin.strip() for origin in args.allow_origins.split(",") if origin.strip()]
209+
210+
config["allow_origins"] = [
211+
origin.strip() for origin in args.allow_origins.split(",") if origin.strip()
212+
]
178213

179214
else:
180215
if os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS") is not None:
181216
# split comma-separated string into list
182217
config["allow_origins"] = [
183-
origin.strip() for origin in os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS", "").split(",")
218+
origin.strip()
219+
for origin in os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS", "").split(",")
184220
if origin.strip()
185221
]
186222
else:
@@ -192,21 +228,24 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
192228
# parse allowed hosts for DNS rebinding protection
193229
if args.allowed_hosts is not None:
194230
# Handle comma-separated string from CLI
195-
config["allowed_hosts"] = [host.strip() for host in args.allowed_hosts.split(",") if host.strip()]
196-
231+
config["allowed_hosts"] = [
232+
host.strip() for host in args.allowed_hosts.split(",") if host.strip()
233+
]
234+
197235
else:
198236
if os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS") is not None:
199237
# split comma-separated string into list
200238
config["allowed_hosts"] = [
201-
host.strip() for host in os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS", "").split(",")
239+
host.strip()
240+
for host in os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS", "").split(",")
202241
if host.strip()
203242
]
204243
else:
205244
logger.info(
206245
"Info: No allowed hosts provided. Defaulting to secure mode - only localhost and 127.0.0.1 allowed."
207246
)
208247
config["allowed_hosts"] = ["localhost", "127.0.0.1"]
209-
248+
210249
# parse token limit
211250
if args.token_limit is not None:
212251
config["token_limit"] = args.token_limit
@@ -232,12 +271,31 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
232271
)
233272
config["read_timeout"] = config["read_timeout"]
234273
except ValueError:
235-
logger.warning("Warning: Invalid read timeout provided. Using default: 30 seconds")
274+
logger.warning(
275+
"Warning: Invalid read timeout provided. Using default: 30 seconds"
276+
)
236277
config["read_timeout"] = 30
237278
else:
238279
logger.info("Info: No read timeout provided. Using default: 30 seconds")
239280
config["read_timeout"] = 30
240281

282+
# parse read-only
283+
if args.read_only:
284+
config["read_only"] = True
285+
logger.info(
286+
f"Info: Read-only mode set to {config['read_only']} via command line argument."
287+
)
288+
elif os.getenv("NEO4J_READ_ONLY") is not None:
289+
config["read_only"] = parse_boolean_safely(os.getenv("NEO4J_READ_ONLY"))
290+
logger.info(
291+
f"Info: Read-only mode set to {config['read_only']} via environment variable."
292+
)
293+
else:
294+
logger.info(
295+
"Info: No read-only setting provided. Write queries will be allowed."
296+
)
297+
config["read_only"] = False
298+
241299
return config
242300

243301

@@ -295,6 +353,7 @@ def _value_sanitize(d: Any, list_limit: int = 128) -> Any:
295353
else:
296354
return d
297355

356+
298357
def _truncate_string_to_tokens(
299358
text: str, token_limit: int, model: str = "gpt-4"
300359
) -> str:

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,51 @@ async def http_server_custom_hosts(setup: Neo4jContainer):
269269
except asyncio.TimeoutError:
270270
process.kill()
271271
await process.wait()
272+
273+
274+
@pytest_asyncio.fixture
275+
async def http_server_read_only(setup: Neo4jContainer):
276+
"""Start the MCP server in HTTP mode with read-only enabled."""
277+
# Start server process in HTTP mode with read-only
278+
process = await asyncio.create_subprocess_exec(
279+
"uv",
280+
"run",
281+
"mcp-neo4j-cypher",
282+
"--transport",
283+
"http",
284+
"--server-host",
285+
"127.0.0.1",
286+
"--server-port",
287+
"8005",
288+
"--read-only",
289+
"--db-url",
290+
setup.get_connection_url(),
291+
"--username",
292+
setup.username,
293+
"--password",
294+
setup.password,
295+
env=os.environ.copy(),
296+
stdout=subprocess.PIPE,
297+
stderr=subprocess.PIPE,
298+
cwd=os.getcwd(),
299+
)
300+
301+
# Wait for server to start
302+
await asyncio.sleep(3)
303+
304+
# Check if process is still running
305+
if process.returncode is not None:
306+
stdout, stderr = await process.communicate()
307+
raise RuntimeError(
308+
f"Read-only server failed to start. stdout: {stdout.decode()}, stderr: {stderr.decode()}"
309+
)
310+
311+
yield process
312+
313+
# Cleanup
314+
try:
315+
process.terminate()
316+
await asyncio.wait_for(process.wait(), timeout=5.0)
317+
except asyncio.TimeoutError:
318+
process.kill()
319+
await process.wait()

0 commit comments

Comments
 (0)