Skip to content

Commit fa0ea3a

Browse files
rfrneo4ja-s-g93
andauthored
data-modeling - add namespacing (#181)
* added namespacing to mcp tools in data modeling server, added tests as well * linting fixes * updating changelog, docs, and fixing namespacing inconsistency * updating tests to pass * move feature to next section of changelog * update readme, namespace utils fn, unit tests --------- Co-authored-by: runfourestrun <[email protected]> Co-authored-by: alex <[email protected]>
1 parent ea02ffa commit fa0ea3a

File tree

8 files changed

+291
-13
lines changed

8 files changed

+291
-13
lines changed

servers/mcp-neo4j-data-modeling/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 namespacing support for multi-tenant deployments with `--namespace` CLI argument and `NEO4J_NAMESPACE` environment variable
89

910
## v0.5.0
1011

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,27 @@ Add the server to your `claude_desktop_config.json` with the transport method sp
193193
}
194194
```
195195

196+
### 🏷️ Namespacing Tools
197+
198+
The server supports namespacing the server tools:
199+
200+
```json
201+
"mcpServers": {
202+
"neo4j-data-modeling-app1": {
203+
"command": "uvx",
204+
"args": [ "[email protected]", "--transport", "stdio", "--namespace", "app1" ]
205+
},
206+
"neo4j-data-modeling-app2": {
207+
"command": "uvx",
208+
"args": [ "[email protected]", "--transport", "stdio", "--namespace", "app2" ]
209+
}
210+
}
211+
```
212+
213+
With namespacing enabled:
214+
- Tools get prefixed: `app1-validate_node`, `app2-validate_node`
215+
- Each namespace operates independently
216+
196217
### 🌐 HTTP Transport Mode
197218

198219
The server supports HTTP transport for web-based deployments and microservices:
@@ -203,6 +224,9 @@ mcp-neo4j-data-modeling --transport http
203224

204225
# Custom HTTP configuration
205226
mcp-neo4j-data-modeling --transport http --host 127.0.0.1 --port 8080 --path /api/mcp/
227+
228+
# With namespace for multi-tenant deployment
229+
mcp-neo4j-data-modeling --transport http --namespace myapp
206230
```
207231

208232
Environment variables for HTTP configuration:
@@ -212,6 +236,7 @@ export MCP_TRANSPORT=http
212236
export NEO4J_MCP_SERVER_HOST=127.0.0.1
213237
export NEO4J_MCP_SERVER_PORT=8080
214238
export NEO4J_MCP_SERVER_PATH=/api/mcp/
239+
export NEO4J_NAMESPACE=myapp
215240
mcp-neo4j-data-modeling
216241
```
217242

@@ -245,6 +270,7 @@ Here we use the Docker Hub hosted Data Modeling MCP server image with stdio tran
245270
"-p",
246271
"8000:8000",
247272
"-e", "NEO4J_TRANSPORT=stdio",
273+
"-e", "NEO4J_NAMESPACE=myapp",
248274
"mcp/neo4j-data-modeling:latest"
249275
]
250276
}
@@ -289,6 +315,7 @@ docker run --rm -p 8000:8000 \
289315
| `NEO4J_MCP_SERVER_HOST` | `127.0.0.1` (local) | Host to bind to |
290316
| `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport |
291317
| `NEO4J_MCP_SERVER_PATH` | `/mcp/` | Path for accessing MCP server |
318+
| `NEO4J_NAMESPACE` | _(empty - no prefix)_ | Namespace prefix for tool names (e.g., `myapp-validate_node`) |
292319
| `NEO4J_MCP_SERVER_ALLOW_ORIGINS` | _(empty - secure by default)_ | Comma-separated list of allowed CORS origins |
293320
| `NEO4J_MCP_SERVER_ALLOWED_HOSTS` | `localhost,127.0.0.1` | Comma-separated list of allowed hosts (DNS rebinding protection) |
294321

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def main():
3030
default=None,
3131
help="Allowed hosts for DNS rebinding protection on remote servers (comma-separated list)",
3232
)
33+
parser.add_argument("--namespace", default=None, help="Tool namespace prefix")
3334

3435
args = parser.parse_args()
3536

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from starlette.middleware import Middleware
88
from starlette.middleware.cors import CORSMiddleware
99
from starlette.middleware.trustedhost import TrustedHostMiddleware
10+
from .utils import format_namespace
1011

1112
from .data_model import (
1213
DataModel,
@@ -29,13 +30,15 @@
2930
logger = logging.getLogger("mcp_neo4j_data_modeling")
3031

3132

32-
def create_mcp_server() -> FastMCP:
33+
def create_mcp_server(namespace: str = "") -> FastMCP:
3334
"""Create an MCP server instance for data modeling."""
3435

3536
mcp: FastMCP = FastMCP(
3637
"mcp-neo4j-data-modeling", dependencies=["pydantic"], stateless_http=True
3738
)
3839

40+
namespace_prefix = format_namespace(namespace)
41+
3942
@mcp.resource("resource://schema/node")
4043
def node_schema() -> dict[str, Any]:
4144
"""Get the schema for a node."""
@@ -108,7 +111,7 @@ def example_health_insurance_fraud_model() -> str:
108111
logger.info("Getting the Health Insurance Fraud Detection data model.")
109112
return json.dumps(HEALTH_INSURANCE_FRAUD_MODEL, indent=2)
110113

111-
@mcp.tool()
114+
@mcp.tool(name=namespace_prefix + "validate_node")
112115
def validate_node(
113116
node: Node, return_validated: bool = False
114117
) -> bool | dict[str, Any]:
@@ -125,7 +128,7 @@ def validate_node(
125128
logger.error(f"Validation error: {e}")
126129
raise ValueError(f"Validation error: {e}")
127130

128-
@mcp.tool()
131+
@mcp.tool(name=namespace_prefix + "validate_relationship")
129132
def validate_relationship(
130133
relationship: Relationship, return_validated: bool = False
131134
) -> bool | dict[str, Any]:
@@ -144,7 +147,7 @@ def validate_relationship(
144147
logger.error(f"Validation error: {e}")
145148
raise ValueError(f"Validation error: {e}")
146149

147-
@mcp.tool()
150+
@mcp.tool(name=namespace_prefix + "validate_data_model")
148151
def validate_data_model(
149152
data_model: DataModel, return_validated: bool = False
150153
) -> bool | dict[str, Any]:
@@ -161,19 +164,19 @@ def validate_data_model(
161164
logger.error(f"Validation error: {e}")
162165
raise ValueError(f"Validation error: {e}")
163166

164-
@mcp.tool()
167+
@mcp.tool(name=namespace_prefix + "load_from_arrows_json")
165168
def load_from_arrows_json(arrows_data_model_dict: dict[str, Any]) -> DataModel:
166169
"Load a data model from the Arrows web application format. Returns a data model as a JSON string."
167170
logger.info("Loading a data model from the Arrows web application format.")
168171
return DataModel.from_arrows(arrows_data_model_dict)
169172

170-
@mcp.tool()
173+
@mcp.tool(name=namespace_prefix + "export_to_arrows_json")
171174
def export_to_arrows_json(data_model: DataModel) -> str:
172175
"Export the data model to the Arrows web application format. Returns a JSON string. This should be presented to the user as an artifact if possible."
173176
logger.info("Exporting the data model to the Arrows web application format.")
174177
return data_model.to_arrows_json_str()
175178

176-
@mcp.tool()
179+
@mcp.tool(name=namespace_prefix + "get_mermaid_config_str")
177180
def get_mermaid_config_str(data_model: DataModel) -> str:
178181
"Get the Mermaid configuration string for the data model. This may be visualized in Claude Desktop and other applications with Mermaid support."
179182
logger.info("Getting the Mermaid configuration string for the data model.")
@@ -184,7 +187,7 @@ def get_mermaid_config_str(data_model: DataModel) -> str:
184187
raise ValueError(f"Validation error: {e}")
185188
return dm_validated.get_mermaid_config_str()
186189

187-
@mcp.tool()
190+
@mcp.tool(name=namespace_prefix + "get_node_cypher_ingest_query")
188191
def get_node_cypher_ingest_query(
189192
node: Node = Field(description="The node to get the Cypher query for."),
190193
) -> str:
@@ -198,7 +201,7 @@ def get_node_cypher_ingest_query(
198201
)
199202
return node.get_cypher_ingest_query_for_many_records()
200203

201-
@mcp.tool()
204+
@mcp.tool(name=namespace_prefix + "get_relationship_cypher_ingest_query")
202205
def get_relationship_cypher_ingest_query(
203206
data_model: DataModel = Field(
204207
description="The data model snippet that contains the relationship, start node and end node."
@@ -228,15 +231,15 @@ def get_relationship_cypher_ingest_query(
228231
relationship_end_node_label,
229232
)
230233

231-
@mcp.tool()
234+
@mcp.tool(name=namespace_prefix + "get_constraints_cypher_queries")
232235
def get_constraints_cypher_queries(data_model: DataModel) -> list[str]:
233236
"Get the Cypher queries to create constraints on the data model. This creates range indexes on the key properties of the nodes and relationships and enforces uniqueness and existence of the key properties."
234237
logger.info(
235238
"Getting the Cypher queries to create constraints on the data model."
236239
)
237240
return data_model.get_cypher_constraints_query()
238241

239-
@mcp.tool()
242+
@mcp.tool(name=namespace_prefix + "get_example_data_model")
240243
def get_example_data_model(
241244
example_name: str = Field(
242245
...,
@@ -270,7 +273,7 @@ def get_example_data_model(
270273
mermaid_config=validated_data_model.get_mermaid_config_str(),
271274
)
272275

273-
@mcp.tool()
276+
@mcp.tool(name=namespace_prefix + "list_example_data_models")
274277
def list_example_data_models() -> dict[str, Any]:
275278
"""List all available example data models with descriptions. Returns a dictionary with example names and their descriptions."""
276279
logger.info("Listing available example data models.")
@@ -401,6 +404,7 @@ def create_new_data_model(
401404

402405
async def main(
403406
transport: Literal["stdio", "sse", "http"] = "stdio",
407+
namespace: str = "",
404408
host: str = "127.0.0.1",
405409
port: int = 8000,
406410
path: str = "/mcp/",
@@ -419,7 +423,7 @@ async def main(
419423
Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts),
420424
]
421425

422-
mcp = create_mcp_server()
426+
mcp = create_mcp_server(namespace=namespace)
423427

424428
match transport:
425429
case "http":

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,28 @@
77

88
ALLOWED_TRANSPORTS = ["stdio", "http", "sse"]
99

10+
def format_namespace(namespace: str) -> str:
11+
"""
12+
Format the namespace to ensure it ends with a hyphen.
13+
14+
Parameters
15+
----------
16+
namespace : str
17+
The namespace to format.
1018
19+
Returns
20+
-------
21+
formatted_namespace : str
22+
The namespace in format: namespace-toolname
23+
"""
24+
if namespace:
25+
if namespace.endswith("-"):
26+
return namespace
27+
else:
28+
return namespace + "-"
29+
else:
30+
return ""
31+
1132
def parse_transport(args: argparse.Namespace) -> Literal["stdio", "http", "sse"]:
1233
"""
1334
Parse the transport from the command line arguments or environment variables.
@@ -265,6 +286,21 @@ def parse_allowed_hosts(args: argparse.Namespace) -> list[str]:
265286
)
266287
return ["localhost", "127.0.0.1"]
267288

289+
def parse_namespace(args: argparse.Namespace) -> str:
290+
"""
291+
Parse the namespace from the command line arguments or environment variables.
292+
"""
293+
# namespace configuration
294+
if args.namespace is not None:
295+
logger.info(f"Info: Namespace provided for tools: {args.namespace}")
296+
return args.namespace
297+
else:
298+
if os.getenv("NEO4J_NAMESPACE") is not None:
299+
logger.info(f"Info: Namespace provided for tools: {os.getenv('NEO4J_NAMESPACE')}")
300+
return os.getenv("NEO4J_NAMESPACE")
301+
else:
302+
logger.info("Info: No namespace provided for tools. No namespace will be used.")
303+
return ""
268304

269305
def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]:
270306
"""
@@ -291,8 +327,13 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
291327
config["port"] = parse_server_port(args, config["transport"])
292328
config["path"] = parse_server_path(args, config["transport"])
293329

330+
# namespace configuration
331+
config["namespace"] = parse_namespace(args)
332+
294333
# middleware configuration
295334
config["allow_origins"] = parse_allow_origins(args)
296335
config["allowed_hosts"] = parse_allowed_hosts(args)
297336

337+
338+
298339
return config

servers/mcp-neo4j-data-modeling/tests/unit/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def clean_env():
2020
"NEO4J_MCP_SERVER_PATH",
2121
"NEO4J_MCP_SERVER_ALLOW_ORIGINS",
2222
"NEO4J_MCP_SERVER_ALLOWED_HOSTS",
23+
"NEO4J_NAMESPACE",
2324
]
2425
# Store original values
2526
original_values = {}
@@ -47,6 +48,7 @@ def _create_args(**kwargs):
4748
"server_path": None,
4849
"allow_origins": None,
4950
"allowed_hosts": None,
51+
"namespace": None,
5052
}
5153
defaults.update(kwargs)
5254
return argparse.Namespace(**defaults)

0 commit comments

Comments
 (0)