Skip to content

Commit 5b9fbdd

Browse files
authored
cypher - add middleware (#165)
* add CORS middleware, update HTTP transport tests, update config parsing, update readme, update changelog * formatting * change dockerfile default back to stdio * allow_methods = POST, GET * update manifest.json change default host to 127.0.0.1 from 0.0.0.0 * host validation middleware, update sse run with middleware, update readme and other conf files * update changelog, dockerfile, docker-compose tested with claude desktop with remote proxy * replace all address examples with `example.com` * fix utils unit tests
1 parent cbb05a1 commit 5b9fbdd

File tree

12 files changed

+673
-25
lines changed

12 files changed

+673
-25
lines changed

servers/mcp-neo4j-cypher/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
### Added
99
* Added Cypher result sanitation function from Neo4j GraphRAG that removes embedding values from the result
10+
* Add env variable `NEO4J_MCP_SERVER_ALLOW_ORIGINS` and cli variable `--allow-origins` to configure CORS Middleware for remote deployments
11+
* Add env variable `NEO4J_MCP_SERVER_ALLOWED_HOSTS` and cli variable `--allowed-hosts` to configure Trusted Hosts Middleware for remote deployments
12+
* Update HTTP and SSE transports to use security middleware
1013
* Added `read_neo4j_cypher` query timeout configuration via `--read-timeout` CLI parameter and `NEO4J_READ_TIMEOUT` environment variable (defaults to 30 seconds)
1114
* Add response token limit for read Cypher responses
1215

servers/mcp-neo4j-cypher/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ ENV NEO4J_USERNAME="neo4j"
2626
ENV NEO4J_PASSWORD="password"
2727
ENV NEO4J_DATABASE="neo4j"
2828
ENV NEO4J_NAMESPACE=""
29-
ENV NEO4J_TRANSPORT="stdio"
29+
ENV NEO4J_TRANSPORT="http"
3030
ENV NEO4J_MCP_SERVER_HOST="0.0.0.0"
3131
ENV NEO4J_MCP_SERVER_PORT="8000"
3232
ENV NEO4J_MCP_SERVER_PATH="/api/mcp/"
33+
ENV NEO4J_MCP_SERVER_ALLOW_ORIGINS=""
34+
ENV NEO4J_MCP_SERVER_ALLOWED_HOSTS="localhost,127.0.0.1"
3335

3436
EXPOSE 8000
3537

servers/mcp-neo4j-cypher/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ test-integration:
88
uv run pytest tests/integration/ -v
99

1010
test-http:
11-
uv run pytest tests/integration/test_http_transport.py -v
11+
uv run pytest tests/integration/test_http_transport_IT.py -v
1212

1313
test-all:
1414
uv run pytest tests/ -v

servers/mcp-neo4j-cypher/README.md

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,61 @@ Choose your transport based on use case:
138138
- **Remote deployment**: Use `http`
139139
- **Legacy web clients**: Use `sse`
140140

141+
## 🔒 Security Protection
142+
143+
The server includes comprehensive security protection with **secure defaults** that protect against common web-based attacks while preserving full MCP functionality when using HTTP transport.
144+
145+
### 🛡️ DNS Rebinding Protection
146+
147+
**TrustedHost Middleware** validates Host headers to prevent DNS rebinding attacks:
148+
149+
**Secure by Default:**
150+
- Only `localhost` and `127.0.0.1` hosts are allowed by default
151+
- Malicious websites cannot trick browsers into accessing your local server
152+
153+
**Environment Variable:**
154+
```bash
155+
export NEO4J_MCP_SERVER_ALLOWED_HOSTS="example.com,www.example.com"
156+
```
157+
158+
### 🌐 CORS Protection
159+
160+
**Cross-Origin Resource Sharing (CORS)** protection blocks browser-based requests by default:
161+
162+
**Environment Variable:**
163+
```bash
164+
export NEO4J_MCP_SERVER_ALLOW_ORIGINS="https://example.com,https://example.com"
165+
```
166+
167+
### 🔧 Complete Security Configuration
168+
169+
**Development Setup:**
170+
```bash
171+
mcp-neo4j-cypher --transport http \
172+
--allowed-hosts "localhost,127.0.0.1" \
173+
--allow-origins "http://localhost:3000"
174+
```
175+
176+
**Production Setup:**
177+
```bash
178+
mcp-neo4j-cypher --transport http \
179+
--allowed-hosts "example.com,www.example.com" \
180+
--allow-origins "https://example.com,https://example.com"
181+
```
182+
183+
184+
### 🚨 Security Best Practices
185+
186+
**For `allow_origins`:**
187+
- Be specific: `["https://example.com", "https://example.com"]`
188+
- Never use `"*"` in production with credentials
189+
- Use HTTPS origins in production
190+
191+
**For `allowed_hosts`:**
192+
- Include your actual domain: `["example.com", "www.example.com"]`
193+
- Include localhost only for development
194+
- Never use `"*"` unless you understand the risks
195+
141196
## 🔧 Usage with Claude Desktop
142197

143198
### Using DXT
@@ -148,7 +203,7 @@ Download the latest `.dxt` file from the [releases page](https://github.com/neo4
148203

149204
Can be found on PyPi https://pypi.org/project/mcp-neo4j-cypher/
150205

151-
Add the server to your `claude_desktop_config.json` with the database connection configuration through environment variables. You may also specify the transport method and namespace with cli arguments or environment variables.
206+
Add the server to your `claude_desktop_config.json` with the database connection configuration through environment variables. You may also specify the transport method, namespace and other config variables with cli arguments or environment variables.
152207

153208
```json
154209
{
@@ -169,17 +224,24 @@ Add the server to your `claude_desktop_config.json` with the database connection
169224

170225
### 🌐 HTTP Transport Configuration
171226

172-
For custom HTTP configurations beyond the defaults:
227+
For custom HTTP configurations with security middleware:
173228

174229
```bash
175-
# Custom HTTP configuration
176-
mcp-neo4j-cypher --transport http --server-host 127.0.0.1 --server-port 8080 --server-path /api/mcp/
177-
178-
# Or using environment variables
230+
# Complete HTTP configuration with security
231+
mcp-neo4j-cypher --transport http \
232+
--server-host 127.0.0.1 \
233+
--server-port 8080 \
234+
--server-path /api/mcp/ \
235+
--allowed-hosts "localhost,127.0.0.1,example.com" \
236+
--allow-origins "https://yourapp.com"
237+
238+
# Using environment variables
179239
export NEO4J_TRANSPORT=http
180240
export NEO4J_MCP_SERVER_HOST=127.0.0.1
181241
export NEO4J_MCP_SERVER_PORT=8080
182242
export NEO4J_MCP_SERVER_PATH=/api/mcp/
243+
export NEO4J_MCP_SERVER_ALLOWED_HOSTS="localhost,127.0.0.1,example.com"
244+
export NEO4J_MCP_SERVER_ALLOW_ORIGINS="https://yourapp.com"
183245
mcp-neo4j-cypher
184246
```
185247

@@ -310,23 +372,39 @@ docker run --rm -p 8000:8000 \
310372
-e NEO4J_MCP_SERVER_PORT="8000" \
311373
-e NEO4J_MCP_SERVER_PATH="/mcp/" \
312374
mcp/neo4j-cypher:latest
375+
376+
# Run with security middleware for production
377+
docker run --rm -p 8000:8000 \
378+
-e NEO4J_URI="bolt://host.docker.internal:7687" \
379+
-e NEO4J_USERNAME="neo4j" \
380+
-e NEO4J_PASSWORD="password" \
381+
-e NEO4J_DATABASE="neo4j" \
382+
-e NEO4J_TRANSPORT="http" \
383+
-e NEO4J_MCP_SERVER_HOST="0.0.0.0" \
384+
-e NEO4J_MCP_SERVER_PORT="8000" \
385+
-e NEO4J_MCP_SERVER_PATH="/mcp/" \
386+
-e NEO4J_MCP_SERVER_ALLOWED_HOSTS="example.com,www.example.com" \
387+
-e NEO4J_MCP_SERVER_ALLOW_ORIGINS="https://example.com" \
388+
mcp/neo4j-cypher:latest
313389
```
314390

315391
### 🔧 Environment Variables
316392

317-
| Variable | Default | Description |
318-
| ----------------------------- | --------------------------------------- | ---------------------------------------------- |
319-
| `NEO4J_URI` | `bolt://localhost:7687` | Neo4j connection URI |
320-
| `NEO4J_USERNAME` | `neo4j` | Neo4j username |
321-
| `NEO4J_PASSWORD` | `password` | Neo4j password |
322-
| `NEO4J_DATABASE` | `neo4j` | Neo4j database name |
323-
| `NEO4J_TRANSPORT` | `stdio` (local), `http` (remote) | Transport protocol (`stdio`, `http`, or `sse`) |
324-
| `NEO4J_NAMESPACE` | _(empty)_ | Tool namespace prefix |
325-
| `NEO4J_MCP_SERVER_HOST` | `127.0.0.1` (local) | Host to bind to |
326-
| `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport |
327-
| `NEO4J_MCP_SERVER_PATH` | `/api/mcp/` | Path for accessing MCP server |
328-
| `NEO4J_RESPONSE_TOKEN_LIMIT` | _(none)_ | Maximum tokens for read query responses |
329-
| `NEO4J_READ_TIMEOUT` | `30` | Timeout in seconds for read queries |
393+
| Variable | Default | Description |
394+
| ---------------------------------- | --------------------------------------- | -------------------------------------------------- |
395+
| `NEO4J_URI` | `bolt://localhost:7687` | Neo4j connection URI |
396+
| `NEO4J_USERNAME` | `neo4j` | Neo4j username |
397+
| `NEO4J_PASSWORD` | `password` | Neo4j password |
398+
| `NEO4J_DATABASE` | `neo4j` | Neo4j database name |
399+
| `NEO4J_TRANSPORT` | `stdio` (local), `http` (remote) | Transport protocol (`stdio`, `http`, or `sse`) |
400+
| `NEO4J_NAMESPACE` | _(empty)_ | Tool namespace prefix |
401+
| `NEO4J_MCP_SERVER_HOST` | `127.0.0.1` (local) | Host to bind to |
402+
| `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport |
403+
| `NEO4J_MCP_SERVER_PATH` | `/api/mcp/` | Path for accessing MCP server |
404+
| `NEO4J_MCP_SERVER_ALLOW_ORIGINS` | _(empty - secure by default)_ | Comma-separated list of allowed CORS origins |
405+
| `NEO4J_MCP_SERVER_ALLOWED_HOSTS` | `localhost,127.0.0.1` | Comma-separated list of allowed hosts (DNS rebinding protection) |
406+
| `NEO4J_RESPONSE_TOKEN_LIMIT` | _(none)_ | Maximum tokens for read query responses |
407+
| `NEO4J_READ_TIMEOUT` | `30` | Timeout in seconds for read queries |
330408

331409
### 🌐 SSE Transport for Legacy Web Access
332410

servers/mcp-neo4j-cypher/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ services:
2525
- NEO4J_MCP_SERVER_PORT=8000
2626
- NEO4J_MCP_SERVER_PATH=/api/mcp/
2727
- NEO4J_NAMESPACE=local
28+
- NEO4J_MCP_SERVER_ALLOWED_HOSTS=localhost,127.0.0.1
29+
- NEO4J_MCP_SERVER_ALLOW_ORIGINS=""
2830
depends_on:
2931
- neo4j
3032

servers/mcp-neo4j-cypher/manifest.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +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_MCP_SERVER_ALLOW_ORIGINS": "${user_config.mcp_server_allow_origins}",
36+
"NEO4J_MCP_SERVER_ALLOWED_HOSTS": "${user_config.mcp_server_allowed_hosts}",
3537
"NEO4J_RESPONSE_TOKEN_LIMIT": "${user_config.token_limit}",
3638
"NEO4J_READ_TIMEOUT": "${user_config.read_timeout}"
3739
}
@@ -127,6 +129,14 @@
127129
"required": false,
128130
"sensitive": false
129131
},
132+
"mcp_server_allow_origins": {
133+
"type": "string",
134+
"title": "MCP Server Allow Origins",
135+
"description": "The allowed origins for the MCP server as a comma-separated list, if not using stdio. Defaults to no allowed origins",
136+
"default": "",
137+
"required": false,
138+
"sensitive": false
139+
},
130140
"token_limit": {
131141
"type": "number",
132142
"title": "Response token limit",
@@ -135,6 +145,14 @@
135145
"required": false,
136146
"sensitive": false
137147
},
148+
"mcp_server_allowed_hosts": {
149+
"type": "string",
150+
"title": "MCP Server Allowed Hosts",
151+
"description": "The allowed hosts for the MCP server as a comma-separated list, if not using stdio. Defaults to localhost and 127.0.0.1",
152+
"default": "localhost,127.0.0.1",
153+
"required": false,
154+
"sensitive": false
155+
},
138156
"read_timeout": {
139157
"type": "number",
140158
"title": "Read timeout",

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ 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+
"--allow-origins",
26+
default=None,
27+
help="Allow origins for remote servers (comma-separated list)",
28+
)
29+
parser.add_argument(
30+
"--allowed-hosts",
31+
default=None,
32+
help="Allowed hosts for DNS rebinding protection on remote servers(comma-separated list)",
33+
)
2434
parser.add_argument(
2535
"--read-timeout",
2636
type=int,

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
from neo4j import AsyncDriver, AsyncGraphDatabase, RoutingControl, Query
1111
from neo4j.exceptions import ClientError, Neo4jError
1212
from pydantic import Field
13+
from starlette.middleware import Middleware
14+
from starlette.middleware.cors import CORSMiddleware
15+
from starlette.middleware.trustedhost import TrustedHostMiddleware
16+
17+
from .utils import _value_sanitize
1318
from .utils import _value_sanitize, _truncate_string_to_tokens
1419

1520
logger = logging.getLogger("mcp_neo4j_cypher")
@@ -263,6 +268,8 @@ async def main(
263268
host: str = "127.0.0.1",
264269
port: int = 8000,
265270
path: str = "/mcp/",
271+
allow_origins: list[str] = [],
272+
allowed_hosts: list[str] = [],
266273
read_timeout: int = 30,
267274
token_limit: Optional[int] = None,
268275
) -> None:
@@ -275,7 +282,17 @@ async def main(
275282
password,
276283
),
277284
)
278-
285+
custom_middleware = [
286+
Middleware(
287+
CORSMiddleware,
288+
allow_origins=allow_origins,
289+
allow_methods=["GET", "POST"],
290+
allow_headers=["*"],
291+
),
292+
Middleware(TrustedHostMiddleware,
293+
allowed_hosts=allowed_hosts)
294+
]
295+
279296
mcp = create_mcp_server(neo4j_driver, database, namespace, read_timeout, token_limit)
280297

281298
# Run the server with the specified transport
@@ -284,15 +301,17 @@ async def main(
284301
logger.info(
285302
f"Running Neo4j Cypher MCP Server with HTTP transport on {host}:{port}..."
286303
)
287-
await mcp.run_http_async(host=host, port=port, path=path)
304+
await mcp.run_http_async(
305+
host=host, port=port, path=path, middleware=custom_middleware
306+
)
288307
case "stdio":
289308
logger.info("Running Neo4j Cypher MCP Server with stdio transport...")
290309
await mcp.run_stdio_async()
291310
case "sse":
292311
logger.info(
293312
f"Running Neo4j Cypher MCP Server with SSE transport on {host}:{port}..."
294313
)
295-
await mcp.run_sse_async(host=host, port=port, path=path)
314+
await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse")
296315
case _:
297316
logger.error(
298317
f"Invalid transport: {transport} | Must be either 'stdio', 'sse', or 'http'"

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,43 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
170170
)
171171
config["path"] = None
172172

173+
# parse allow origins
174+
if args.allow_origins is not None:
175+
# Handle comma-separated string from CLI
176+
177+
config["allow_origins"] = [origin.strip() for origin in args.allow_origins.split(",") if origin.strip()]
178+
179+
else:
180+
if os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS") is not None:
181+
# split comma-separated string into list
182+
config["allow_origins"] = [
183+
origin.strip() for origin in os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS", "").split(",")
184+
if origin.strip()
185+
]
186+
else:
187+
logger.info(
188+
"Info: No allow origins provided. Defaulting to no allowed origins."
189+
)
190+
config["allow_origins"] = list()
191+
192+
# parse allowed hosts for DNS rebinding protection
193+
if args.allowed_hosts is not None:
194+
# Handle comma-separated string from CLI
195+
config["allowed_hosts"] = [host.strip() for host in args.allowed_hosts.split(",") if host.strip()]
196+
197+
else:
198+
if os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS") is not None:
199+
# split comma-separated string into list
200+
config["allowed_hosts"] = [
201+
host.strip() for host in os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS", "").split(",")
202+
if host.strip()
203+
]
204+
else:
205+
logger.info(
206+
"Info: No allowed hosts provided. Defaulting to secure mode - only localhost and 127.0.0.1 allowed."
207+
)
208+
config["allowed_hosts"] = ["localhost", "127.0.0.1"]
209+
173210
# parse token limit
174211
if args.token_limit is not None:
175212
config["token_limit"] = args.token_limit
@@ -258,7 +295,6 @@ def _value_sanitize(d: Any, list_limit: int = 128) -> Any:
258295
else:
259296
return d
260297

261-
262298
def _truncate_string_to_tokens(
263299
text: str, token_limit: int, model: str = "gpt-4"
264300
) -> str:

0 commit comments

Comments
 (0)