Skip to content

Commit e066671

Browse files
authored
data-modeling - add middleware (#176)
1 parent d432187 commit e066671

File tree

8 files changed

+222
-12
lines changed

8 files changed

+222
-12
lines changed

servers/mcp-neo4j-data-modeling/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
* Change default transport to `stdio` in Dockerfile
1010

1111
### Added
12+
* Add security middleware (CORS and TrustedHost) for HTTP and SSE transports
13+
* Add CLI support for `--allow-origins` and `--allowed-hosts` configuration
14+
* Add environment variable for `NEO4J_MCP_SERVER_ALLOW_ORIGINS` and `NEO4J_MCP_SERVER_ALLOWED_HOSTS` configuration
1215

1316
## v0.4.0
1417

servers/mcp-neo4j-data-modeling/Makefile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# Makefile for cypher-guard Python bindings
2-
31
.PHONY: format test clean inspector build_local_docker_image install-dev test-unit test-integration test-http test-all all
42

53
format:

servers/mcp-neo4j-data-modeling/README.md

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,19 +225,93 @@ The server supports three transport modes:
225225

226226
### 🐳 Using with Docker
227227

228+
Here we use the Docker Hub hosted Data Modeling MCP server image with stdio transport for use with Claude Desktop.
229+
230+
**Config details:**
231+
* `-i`: Interactive mode - keeps STDIN open for stdio transport communication
232+
* `--rm`: Automatically remove container when it exits (cleanup)
233+
* `-p 8000:8000`: Port mapping - maps host port 8000 to container port 8000
234+
* `NEO4J_TRANSPORT=stdio`: Uses stdio transport for Claude Desktop compatibility
235+
228236
```json
229-
"mcpServers": {
230-
"neo4j-data-modeling": {
231-
"command": "docker",
232-
"args": [
233-
"run",
234-
"--rm",
235-
"mcp/neo4j-data-modeling:latest"
236-
]
237+
{
238+
"mcpServers": {
239+
"neo4j-data-modeling": {
240+
"command": "docker",
241+
"args": [
242+
"run",
243+
"-i",
244+
"--rm",
245+
"-p",
246+
"8000:8000",
247+
"-e", "NEO4J_TRANSPORT=stdio",
248+
"mcp/neo4j-data-modeling:latest"
249+
]
250+
}
237251
}
238252
}
239253
```
240254

255+
256+
## 🐳 Docker Deployment
257+
258+
The Neo4j Data Modeling MCP server can be deployed using Docker for remote deployments. Docker deployment should use HTTP transport for web accessibility. In order to integrate this deployment with applications like Claude Desktop, you will have to use a proxy in your MCP configuration such as `mcp-remote`.
259+
260+
### 📦 Using Your Built Image
261+
262+
After building locally with `docker build -t mcp-neo4j-data-modeling:latest .`:
263+
264+
```bash
265+
# Run with http transport (default for Docker)
266+
docker run --rm -p 8000:8000 \
267+
-e NEO4J_TRANSPORT="http" \
268+
-e NEO4J_MCP_SERVER_HOST="0.0.0.0" \
269+
-e NEO4J_MCP_SERVER_PORT="8000" \
270+
-e NEO4J_MCP_SERVER_PATH="/mcp/" \
271+
mcp/neo4j-data-modeling:latest
272+
273+
# Run with security middleware for production
274+
docker run --rm -p 8000:8000 \
275+
-e NEO4J_TRANSPORT="http" \
276+
-e NEO4J_MCP_SERVER_HOST="0.0.0.0" \
277+
-e NEO4J_MCP_SERVER_PORT="8000" \
278+
-e NEO4J_MCP_SERVER_PATH="/mcp/" \
279+
-e NEO4J_MCP_SERVER_ALLOWED_HOSTS="example.com,www.example.com" \
280+
-e NEO4J_MCP_SERVER_ALLOW_ORIGINS="https://example.com" \
281+
mcp/neo4j-data-modeling:latest
282+
```
283+
284+
### 🔧 Environment Variables
285+
286+
| Variable | Default | Description |
287+
| ---------------------------------- | --------------------------------------- | -------------------------------------------------- |
288+
| `NEO4J_TRANSPORT` | `stdio` (local), `http` (remote) | Transport protocol (`stdio`, `http`, or `sse`) |
289+
| `NEO4J_MCP_SERVER_HOST` | `127.0.0.1` (local) | Host to bind to |
290+
| `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport |
291+
| `NEO4J_MCP_SERVER_PATH` | `/mcp/` | Path for accessing MCP server |
292+
| `NEO4J_MCP_SERVER_ALLOW_ORIGINS` | _(empty - secure by default)_ | Comma-separated list of allowed CORS origins |
293+
| `NEO4J_MCP_SERVER_ALLOWED_HOSTS` | `localhost,127.0.0.1` | Comma-separated list of allowed hosts (DNS rebinding protection) |
294+
295+
### 🌐 SSE Transport for Legacy Web Access
296+
297+
When using SSE transport (for legacy web clients), the server exposes an HTTP endpoint:
298+
299+
```bash
300+
# Start the server with SSE transport
301+
docker run -d -p 8000:8000 \
302+
-e NEO4J_TRANSPORT="sse" \
303+
-e NEO4J_MCP_SERVER_HOST="0.0.0.0" \
304+
-e NEO4J_MCP_SERVER_PORT="8000" \
305+
--name neo4j-data-modeling-mcp-server \
306+
mcp-neo4j-data-modeling:latest
307+
308+
# Test the SSE endpoint
309+
curl http://localhost:8000/sse
310+
311+
# Use with MCP Inspector
312+
npx @modelcontextprotocol/inspector http://localhost:8000/sse
313+
```
314+
241315
## 🚀 Development
242316

243317
### 📦 Prerequisites

servers/mcp-neo4j-data-modeling/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ requires-python = ">=3.10,<4.0"
77
dependencies = [
88
"fastmcp>=2.0.0",
99
"pydantic>=2.10.1",
10+
"starlette>=0.47.0",
1011
]
1112

1213

servers/mcp-neo4j-data-modeling/src/mcp_neo4j_data_modeling/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,38 @@ def main():
1616
"--server-port", type=int, default=None, help="HTTP port (default: 8000)"
1717
)
1818
parser.add_argument("--server-path", default=None, help="HTTP path (default: /mcp/)")
19+
parser.add_argument(
20+
"--allow-origins",
21+
default=None,
22+
help="Allow origins for remote servers (comma-separated list)",
23+
)
24+
parser.add_argument(
25+
"--allowed-hosts",
26+
default=None,
27+
help="Allowed hosts for DNS rebinding protection on remote servers (comma-separated list)",
28+
)
1929

2030
args = parser.parse_args()
31+
32+
# Parse comma-separated lists for middleware configuration
33+
allow_origins = []
34+
if args.allow_origins or os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS"):
35+
origins_str = args.allow_origins or os.getenv("NEO4J_MCP_SERVER_ALLOW_ORIGINS", "")
36+
allow_origins = [origin.strip() for origin in origins_str.split(",") if origin.strip()]
37+
38+
allowed_hosts = ["localhost", "127.0.0.1"] # Default secure hosts
39+
if args.allowed_hosts or os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS"):
40+
hosts_str = args.allowed_hosts or os.getenv("NEO4J_MCP_SERVER_ALLOWED_HOSTS", "")
41+
allowed_hosts = [host.strip() for host in hosts_str.split(",") if host.strip()]
42+
2143
asyncio.run(
2244
server.main(
2345
args.transport or os.getenv("NEO4J_TRANSPORT", "stdio"),
2446
args.server_host or os.getenv("NEO4J_MCP_SERVER_HOST", "127.0.0.1"),
2547
args.server_port or int(os.getenv("NEO4J_MCP_SERVER_PORT", "8000")),
2648
args.server_path or os.getenv("NEO4J_MCP_SERVER_PATH", "/mcp/"),
49+
allow_origins,
50+
allowed_hosts,
2751
)
2852
)
2953

servers/mcp-neo4j-data-modeling/src/mcp_neo4j_data_modeling/server.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
from fastmcp.server import FastMCP
66
from pydantic import Field, ValidationError
7+
from starlette.middleware import Middleware
8+
from starlette.middleware.cors import CORSMiddleware
9+
from starlette.middleware.trustedhost import TrustedHostMiddleware
710

811
from .data_model import (
912
DataModel,
@@ -395,18 +398,38 @@ async def main(
395398
host: str = "127.0.0.1",
396399
port: int = 8000,
397400
path: str = "/mcp/",
401+
allow_origins: list[str] = [],
402+
allowed_hosts: list[str] = [],
398403
) -> None:
399404
logger.info("Starting MCP Neo4j Data Modeling Server")
400405

406+
custom_middleware = [
407+
Middleware(
408+
CORSMiddleware,
409+
allow_origins=allow_origins,
410+
allow_methods=["GET", "POST"],
411+
allow_headers=["*"],
412+
),
413+
Middleware(TrustedHostMiddleware,
414+
allowed_hosts=allowed_hosts)
415+
]
416+
401417
mcp = create_mcp_server()
402418

403419
match transport:
404420
case "http":
405-
await mcp.run_http_async(host=host, port=port, path=path)
421+
logger.info(
422+
f"Running Neo4j Data Modeling MCP Server with HTTP transport on {host}:{port}..."
423+
)
424+
await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware)
406425
case "stdio":
426+
logger.info("Running Neo4j Data Modeling MCP Server with stdio transport...")
407427
await mcp.run_stdio_async()
408428
case "sse":
409-
await mcp.run_sse_async(host=host, port=port, path=path)
429+
logger.info(
430+
f"Running Neo4j Data Modeling MCP Server with SSE transport on {host}:{port}..."
431+
)
432+
await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse")
410433

411434

412435
if __name__ == "__main__":

servers/mcp-neo4j-data-modeling/tests/integration/test_http_transport_IT.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,3 +486,88 @@ async def test_full_workflow(self):
486486
finally:
487487
process.terminate()
488488
await process.wait()
489+
490+
491+
class TestMiddleware:
492+
"""Test middleware functionality in HTTP transport."""
493+
494+
@pytest_asyncio.fixture
495+
async def http_server_with_middleware(self):
496+
"""Start the server in HTTP mode with middleware configuration."""
497+
import os
498+
499+
server_dir = os.getcwd()
500+
501+
process = await asyncio.create_subprocess_exec(
502+
"uv",
503+
"run",
504+
"mcp-neo4j-data-modeling",
505+
"--transport",
506+
"http",
507+
"--server-host",
508+
"127.0.0.1",
509+
"--server-port",
510+
"8010",
511+
"--allow-origins",
512+
"https://example.com,https://test.com",
513+
"--allowed-hosts",
514+
"localhost,127.0.0.1,example.com",
515+
stdout=subprocess.PIPE,
516+
stderr=subprocess.PIPE,
517+
cwd=server_dir,
518+
)
519+
520+
await asyncio.sleep(3)
521+
yield process
522+
process.terminate()
523+
await process.wait()
524+
525+
@pytest.mark.asyncio
526+
async def test_cors_headers(self, http_server_with_middleware):
527+
"""Test CORS middleware is working."""
528+
async with aiohttp.ClientSession() as session:
529+
async with session.options(
530+
"http://127.0.0.1:8010/mcp/",
531+
headers={
532+
"Origin": "https://example.com",
533+
"Access-Control-Request-Method": "POST",
534+
"Access-Control-Request-Headers": "Content-Type",
535+
},
536+
) as response:
537+
# CORS preflight should return 200 with appropriate headers
538+
assert response.status == 200
539+
assert "access-control-allow-origin" in response.headers
540+
assert "access-control-allow-methods" in response.headers
541+
542+
@pytest.mark.asyncio
543+
async def test_trusted_host_security(self, http_server_with_middleware):
544+
"""Test TrustedHost middleware blocks invalid hosts."""
545+
async with aiohttp.ClientSession() as session:
546+
# This should work with valid host
547+
async with session.post(
548+
"http://127.0.0.1:8010/mcp/",
549+
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
550+
headers={
551+
"Accept": "application/json, text/event-stream",
552+
"Content-Type": "application/json",
553+
"Host": "127.0.0.1:8010",
554+
},
555+
) as response:
556+
assert response.status == 200
557+
558+
# This should be blocked by TrustedHost middleware
559+
try:
560+
async with session.post(
561+
"http://127.0.0.1:8010/mcp/",
562+
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
563+
headers={
564+
"Accept": "application/json, text/event-stream",
565+
"Content-Type": "application/json",
566+
"Host": "malicious.com",
567+
},
568+
) as response:
569+
# Should return 400 status for invalid host
570+
assert response.status == 400
571+
except aiohttp.ClientConnectorError:
572+
# Connection might be dropped entirely, which is also valid
573+
pass

servers/mcp-neo4j-data-modeling/uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)