From 39fdbf671567838e965e590703125055bddb4645 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Wed, 3 Dec 2025 12:11:35 +0000 Subject: [PATCH 01/21] Add Docker support with Nginx and update README for usage instructions --- Dockerfile | 31 +++++++++++++ README.md | 15 ++++++ nginx.conf | 97 +++++++++++++++++++++++++++++++++++++++ pdbe_mcp_server/server.py | 10 +++- start.sh | 10 ++++ 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 nginx.conf create mode 100644 start.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..820ae5a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Multi-service container: runs 3 MCP SSE servers and Nginx reverse proxy +FROM python:3.11-slim + +# Install system packages: nginx and curl (for healthcheck) +RUN apt-get update \ + && apt-get install -y --no-install-recommends nginx curl \ + && rm -rf /var/lib/apt/lists/* + +# Prepare nginx runtime dir +RUN mkdir -p /var/run/nginx + +# Set workdir and copy app source +WORKDIR /app +COPY . /app + +# Install Python dependencies from project +RUN pip install --no-cache-dir . + +# Copy Nginx configuration (standalone version with localhost) +COPY nginx.conf /etc/nginx/nginx.conf +COPY start.sh /start.sh +RUN chmod +x /start.sh + +# Expose Nginx port +EXPOSE 8080 + +# Healthcheck: ensure Nginx responds +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://localhost:8080/health || exit 1 + +# Run all services via start script (uvicorn x3 + nginx) +CMD ["/start.sh"] diff --git a/README.md b/README.md index 8461a52..5b20b79 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,21 @@ Common searchable fields include: Use `get_search_schema` to discover all available fields and their descriptions. ## Development and Advanced Usage +### Docker (single container with Nginx) + +Build and run all three MCP servers behind Nginx using the included Dockerfile: + +```bash +docker build -t pdbe-mcp-servers:latest . +docker run --rm -p 8080:8080 pdbe-mcp-servers:latest +``` + +Endpoints: +- `http://localhost:8080/api/sse` and `http://localhost:8080/api/messages/` +- `http://localhost:8080/graph/sse` and `http://localhost:8080/graph/messages/` +- `http://localhost:8080/search/sse` and `http://localhost:8080/search/messages/` +- Health: `http://localhost:8080/health` + ### Development Installation diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..fed2717 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,97 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + map $request_method $is_options { + default 0; + "OPTIONS" 1; + } + + # Simple health endpoint + server { + listen 8080; + server_name _; + + # Health check + location = /health { + add_header Content-Type text/plain; + return 200 'ok\n'; + } + + + # API server - proxy entire /api prefix + location /api/ { + + # CORS + if ($is_options) { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers '*'; + return 204; + } + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers '*'; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ''; + proxy_buffering off; # critical for SSE + add_header X-Accel-Buffering no; # disable nginx buffering + + proxy_pass http://127.0.0.1:8010/; + } + + # Graph server - proxy entire /graph prefix + location /graph/ { + + if ($is_options) { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers '*'; + return 204; + } + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers '*'; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ''; + proxy_buffering off; + add_header X-Accel-Buffering no; + + proxy_pass http://127.0.0.1:8020/; + } + + # Search server - proxy entire /search prefix + location /search/ { + + if ($is_options) { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers '*'; + return 204; + } + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; + add_header Access-Control-Allow-Headers '*'; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ''; + proxy_buffering off; + add_header X-Accel-Buffering no; + + proxy_pass http://127.0.0.1:8030/; + } + } +} diff --git a/pdbe_mcp_server/server.py b/pdbe_mcp_server/server.py index 06baec5..137f248 100644 --- a/pdbe_mcp_server/server.py +++ b/pdbe_mcp_server/server.py @@ -164,6 +164,12 @@ def main(port: int, transport: str, server_type: str) -> int: sse = SseServerTransport("/messages/") + root_paths = { + "pdbe_api_server": "/api", + "pdbe_graph_server": "/graph", + "pdbe_search_server": "/search", + } + async def handle_sse(request): async with sse.connect_sse( request.scope, @@ -183,7 +189,9 @@ async def handle_sse(request): ], ) - uvicorn.run(starlette_app, host="0.0.0.0", port=port) + uvicorn.run( + starlette_app, host="0.0.0.0", port=port, root_path=root_paths[server_type] + ) else: from mcp.server.stdio import stdio_server diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..7ca3e0d --- /dev/null +++ b/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -euo pipefail + +# Launch three MCP servers in SSE mode on distinct ports +pdbe-mcp-server --transport sse --server-type pdbe_api_server --port 8010 & +pdbe-mcp-server --transport sse --server-type pdbe_graph_server --port 8020 & +pdbe-mcp-server --transport sse --server-type pdbe_search_server --port 8030 & + +# Start nginx in foreground +exec nginx -g 'daemon off;' From 52957ebb86bd116727a52f230bf015be4b66cca0 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Wed, 3 Dec 2025 12:12:38 +0000 Subject: [PATCH 02/21] Bump pdbe-mcp-server version to 1.0.1 in uv.lock --- nginx.conf | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nginx.conf b/nginx.conf index fed2717..4e9431d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -29,7 +29,7 @@ http { # API server - proxy entire /api prefix location /api/ { - + # CORS if ($is_options) { add_header Access-Control-Allow-Origin *; diff --git a/uv.lock b/uv.lock index 7678e2f..5e20215 100644 --- a/uv.lock +++ b/uv.lock @@ -635,7 +635,7 @@ wheels = [ [[package]] name = "pdbe-mcp-server" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "anyio" }, From 3d656a6aaca5c0dbb1767c051e8597b687898dc1 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Wed, 3 Dec 2025 14:35:24 +0000 Subject: [PATCH 03/21] Add GitLab CI configuration for Docker build and deployment --- .gitlab-ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5227684 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,28 @@ +docker_build: + stage: build + image: + name: gcr.io/kaniko-project/executor:debug + entrypoint: [""] + script: + - | + mkdir -p /kaniko/.docker + - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json + - /kaniko/executor --context $CI_PROJECT_DIR --destination ${CI_REGISTRY_IMAGE}/app:$CI_COMMIT_SHORT_SHA + tags: + - pdbe-docker + +trigger_deploy: + stage: deploy + inherit: + variables: false + variables: + UPSTREAM_REF: $CI_COMMIT_REF_NAME + IMAGE_PATHS: ${CI_REGISTRY_IMAGE}/app:$CI_COMMIT_SHORT_SHA + IMAGE_NAMES: pdbe-mcp-server + APP_NAME: pdbe-mcp-server + trigger: + project: pdbe/backend/k8s-deploy-configs + branch: main + strategy: depend + needs: + - docker_build From 955abe1eea6dd19c161a0b65ac52a4eac123d086 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Thu, 4 Dec 2025 15:32:32 +0000 Subject: [PATCH 04/21] Allow POST method for SSE endpoint in main server route --- pdbe_mcp_server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdbe_mcp_server/server.py b/pdbe_mcp_server/server.py index 137f248..e9dd0a8 100644 --- a/pdbe_mcp_server/server.py +++ b/pdbe_mcp_server/server.py @@ -184,7 +184,7 @@ async def handle_sse(request): starlette_app = Starlette( debug=True, routes=[ - Route("/sse", endpoint=handle_sse, methods=["GET"]), + Route("/sse", endpoint=handle_sse, methods=["GET", "POST"]), Mount("/messages/", app=sse.handle_post_message), ], ) From 207ad3191ee305037406d2e9dc8133ae9dd1ec1b Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Thu, 4 Dec 2025 15:44:10 +0000 Subject: [PATCH 05/21] Add ROOT_PREFIX support for server route paths --- pdbe_mcp_server/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pdbe_mcp_server/server.py b/pdbe_mcp_server/server.py index e9dd0a8..4a526fe 100644 --- a/pdbe_mcp_server/server.py +++ b/pdbe_mcp_server/server.py @@ -1,3 +1,4 @@ +import os from typing import Any, Callable, Sequence import anyio @@ -16,6 +17,8 @@ conf: DictConfig = get_config() +ROOT_PREFIX = os.getenv("ROOT_PREFIX", "") + class MCPServerFactory: """ @@ -165,9 +168,9 @@ def main(port: int, transport: str, server_type: str) -> int: sse = SseServerTransport("/messages/") root_paths = { - "pdbe_api_server": "/api", - "pdbe_graph_server": "/graph", - "pdbe_search_server": "/search", + "pdbe_api_server": f"{ROOT_PREFIX}/api", + "pdbe_graph_server": f"{ROOT_PREFIX}/graph", + "pdbe_search_server": f"{ROOT_PREFIX}/search", } async def handle_sse(request): From 4ce6ab730f5d0e44daa2551232c3d6e4e4ceffb5 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 16:24:16 +0000 Subject: [PATCH 06/21] Added graph run functionality --- .coverage | Bin 53248 -> 53248 bytes README.md | 61 +++++++ pdbe_mcp_server/graph_tools.py | 290 +++++++++++++++++++++++++++++++++ pdbe_mcp_server/server.py | 34 +++- pyproject.toml | 1 + tests/test_graph_tools.py | 87 ++++++++++ uv.lock | 25 ++- 7 files changed, 496 insertions(+), 2 deletions(-) diff --git a/.coverage b/.coverage index ae273f5c50bb4212d650a2e1c621e34f21f7c186..ddf87ec65b6f1ab17f21b093eabc527e05871a17 100644 GIT binary patch delta 264 zcmZozz}&Eac>`O6fG7k1cmCu2_54Nr;rvGYqMHQ;{`1vKv$HUAS}>O#%eQuBHowFC zq53}~LrV2ueg=n4jLK|4Ne}j4_l&lg7k!?<%V5I*1SgoA*%^xd?E|qH7+54(fr?Dn zwmoHLVEA*_PLARI`O6fENS*cmC7-%lT*VH}WU)dupnc*nizK+Gbw#c>=H81a>xlh9`e&85ux;L!1>TZ@_lz6OjKePoBTw z`^U@YtKZ&N=Vk#)NU$31UH|2`mJ&0NFUV*0O$^cHsLo zhB7X_2aH-wKt*9p@1`}1SG;G~lv`-U@cCZl@&nsuFiyBr@y!0`V&(_4Z7SKnJ!RnA J{Jo#k0RR=0O#lD@ diff --git a/README.md b/README.md index 389d6d8..241c59b 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,44 @@ The PDBe Search Server provides powerful querying capabilities through the PDBe ### Available Tools +#### `pdbe_graph_nodes` +Retrieves metadata about all node types (labels) defined in the PDBe graph database schema. Use this to understand the different types of entities and their properties. + +**Example usage:** +``` +"Show me all node types in the PDBe graph database" +``` + +#### `pdbe_graph_edges` +Retrieves metadata about all relationship types (edges) defined in the PDBe graph database schema. Use this to understand how entities are connected. + +**Example usage:** +``` +"Show me all relationship types in the PDBe graph database" +``` + +#### `pdbe_graph_example_queries` +Retrieves example Cypher queries that demonstrate how to interact with the PDBe graph database. + +**Example usage:** +``` +"Give me example Cypher queries for exploring the PDBe graph" +``` + +#### `pdbe_run_cypher_query` +Execute custom read-only Cypher queries against a Neo4j graph database. Only available when Neo4j environment variables are configured. + +**Parameters:** +- `cypher_query` (required): The Cypher query to execute. Only MATCH and OPTIONAL MATCH queries are allowed. + +**Example usage:** +``` +"Execute query: MATCH (s:Structure) WHERE s.PDB_ID = '1abc' RETURN s.TITLE as title" +"Find ligands: MATCH (s:Structure)-[:HAS_LIGAND]->(l:Ligand) WHERE s.PDB_ID = '1abc' RETURN l.name" +``` + +> **Note:** Write operations (MERGE, CREATE, DELETE, REMOVE, SET, LOAD CSV, FOREACH) are blocked for safety. + #### `get_search_schema` Retrieves the complete Solr search schema showing all available fields, data types, and descriptions. Use this to understand what fields you can search and filter on. @@ -239,6 +277,29 @@ uvx pdbe-mcp-server --server-type pdbe_graph_server --transport sse uv run pdbe-mcp-server --server-type pdbe_graph_server --transport sse ``` +### Neo4j Cypher Query Support + +This server supports executing custom Cypher queries against a Neo4j graph database. The `pdbe_run_cypher_query` tool is only available when the following environment variables are set: + +- `NEO4J_URL`: The Neo4j database URL (e.g., `bolt://localhost:7687`) +- `NEO4J_USERNAME`: The Neo4j username +- `NEO4J_PASSWORD`: The Neo4j password +- `NEO4J_DATABASE` (optional): The database name. When set, this is passed to the Neo4j driver for Neo4j 4+. For Neo4j 3.5 compatibility, omit this variable to use the default database. + +To use this feature, install the neo4j driver: +```bash +pip install neo4j +``` + +**Security:** Only read-only queries are allowed (MATCH, OPTIONAL MATCH). Write operations (MERGE, CREATE, DELETE, REMOVE, SET, LOAD CSV, FOREACH) are blocked to prevent accidental data modification. + +**Example usage:** +``` +Execute query: MATCH (s:Structure) WHERE s.PDB_ID = '1abc' RETURN s.PDB_ID as id, s.TITLE as title +``` + +The tool response is formatted as JSON by default, but can be converted to TOON format by setting `TOON_ENABLED=true`. + #### PDBe Search Server Provides advanced Solr-based search and analytics capabilities: diff --git a/pdbe_mcp_server/graph_tools.py b/pdbe_mcp_server/graph_tools.py index 96b8bc7..984701d 100644 --- a/pdbe_mcp_server/graph_tools.py +++ b/pdbe_mcp_server/graph_tools.py @@ -1,3 +1,6 @@ +import logging +import os +import re from typing import Any import mcp.types as types @@ -6,9 +9,124 @@ from pdbe_mcp_server import get_config from pdbe_mcp_server.utils import HTMLStripper, HTTPClient +logger = logging.getLogger(__name__) + conf: DictConfig = get_config() +def _get_neo4j_config_from_env() -> dict[str, str] | None: + """ + Get Neo4j configuration from environment variables. + + Returns: + Dictionary with neo4j_url, neo4j_username, neo4j_password, and neo4j_database, + or None if not all required variables are set. + """ + neo4j_url = os.getenv("NEO4J_URL") + neo4j_username = os.getenv("NEO4J_USERNAME") + neo4j_password = os.getenv("NEO4J_PASSWORD") + neo4j_database = os.getenv("NEO4J_DATABASE") + + if neo4j_url and neo4j_username and neo4j_password: + config = { + "neo4j_url": neo4j_url, + "neo4j_username": neo4j_username, + "neo4j_password": neo4j_password, + } + if neo4j_database: + config["neo4j_database"] = neo4j_database + return config + return None + + +def _neo4j_enabled() -> bool: + """Check if Neo4j environment variables are set.""" + return _get_neo4j_config_from_env() is not None + + +def _toon_enabled() -> bool: + """Check if TOON output is enabled.""" + return os.getenv("TOON_ENABLED", "false").lower() == "true" + + +def _validate_cypher_query(query: str) -> tuple[bool, str | None]: + """ + Validate a Cypher query to ensure it is read-only (no write, delete, or update operations). + + Args: + query: The Cypher query to validate. + + Returns: + Tuple of (is_valid, error_message). If valid, error_message is None. + """ + # Normalize the query: remove comments, extra whitespace, convert to uppercase for matching + normalized = re.sub(r"/\*.*?\*/", "", query, flags=re.DOTALL) + normalized = re.sub(r"--.*$", "", normalized, flags=re.MULTILINE) + normalized = " ".join(normalized.upper().split()) + + # Cypher keywords that indicate write, delete, or update operations + write_patterns = [ + r"\bMERGE\b", + r"\bCREATE\b", + r"\bDELETE\b", + r"\bREMOVE\b", + r"\bSET\b", + r"\bADD\b", + r"\bREMOVE\b", + r"\bSET\b", + r"\bSET\s+[A-Za-z_][A-Za-z0-9_]*\s+=", + r"\bMATCH\b.*\bMERGE\b", + r"\bMERGE\b.*\bSET\b", + r"\bCREATE\b.*\bSET\b", + r"\bWITH\b.*\bMERGE\b", + r"\bWITH\b.*\bCREATE\b", + r"\bWITH\b.*\bDELETE\b", + r"\bWITH\b.*\bSET\b", + r"\bLOAD\s+CSV\b", + r"\bFOREACH\b", + r"\bREMOVE\b\b", + ] + + for pattern in write_patterns: + if re.search(pattern, normalized): + return ( + False, + f"Query contains potentially destructive operation (detected pattern: {pattern})", + ) + + # Additional check: allow only MATCH, OPTIONAL MATCH, CALL {MATCH ...}, RETURN + # This is a safer approach - only allow queries that start with these read operations + allowed_starts = [ + r"^(?:MATCH|OPTIONAL\s+MATCH|CALL\s*\{[^}]*\})", + ] + + # Check if query matches allowed patterns + has_allowed_pattern = any(re.search(p, normalized) for p in allowed_starts) + + # Additional check: if query contains write keywords after MATCH, it might be dangerous + # This catches patterns like "MATCH ... RETURN ... MERGE" + write_keywords = ["MERGE", "CREATE", "DELETE", "REMOVE", "SET"] + if has_allowed_pattern: + # Check if any write operation appears after the initial MATCH/MATCH+CALL + parts = re.split(r"\bRETURN\b", normalized, flags=re.IGNORECASE) + if len(parts) > 1: + # Everything after RETURN is part of RETURN clause, check the rest + pre_return = parts[0] + for keyword in write_keywords: + if re.search(rf"\b{keyword}\b", pre_return, re.IGNORECASE): + return ( + False, + f"Query contains potentially destructive operation ({keyword}) after MATCH", + ) + elif not has_allowed_pattern: + return ( + False, + "Query does not start with allowed read operation (MATCH, OPTIONAL MATCH, or CALL)", + ) + + return True, None + + class GraphTools: """ A class to handle PDBe graph-related operations. @@ -131,6 +249,46 @@ def get_pdbe_graph_example_queries_tool(self) -> types.Tool: ), ) + def get_pdbe_run_cypher_query_tool(self) -> types.Tool: + return types.Tool( + name="pdbe_run_cypher_query", + description=""" + Execute a read-only Cypher query against the PDBe (PDBe-KB) Neo4j graph database. + This tool allows you to run custom MATCH or OPTIONAL MATCH queries to explore complex relationships and data in the PDBe graph. + Only read-only queries are allowed (MATCH, OPTIONAL MATCH, CALL {MATCH ...}). + Write operations (MERGE, CREATE, DELETE, REMOVE, SET, LOAD CSV, FOREACH) are not permitted for safety. + + Parameters: + cypher_query (required): The Cypher query to execute. Example: "MATCH (s:Structure) WHERE s.PDB_ID = '1abc' RETURN s" + + Expected Output Format: + JSON array of result objects, or in TOON format if TOON_ENABLED is set. + Each object represents a row in the result set with column names as keys. + + Example queries: + MATCH (s:Structure) WHERE s.PDB_ID = '1abc' RETURN s.PDB_ID as id, s.TITLE as title + MATCH (s:Structure)-[r:HAS_LIGAND]->(l:Ligand) WHERE s.PDB_ID = '1abc' RETURN l.name as ligand, count(r) as binding_count + OPTIONAL MATCH (s:Structure) WHERE s.PDB_ID = '9xyz' RETURN s.PDB_ID as id, s.TITLE as title + """, + inputSchema={ + "type": "object", + "properties": { + "cypher_query": { + "type": "string", + "description": "The Cypher query to execute. Only MATCH and OPTIONAL MATCH queries are allowed. MERGE, CREATE, DELETE, REMOVE, SET, LOAD CSV, and FOREACH operations are not permitted.", + } + }, + "required": ["cypher_query"], + "additionalProperties": False, + }, + annotations=types.ToolAnnotations( + title="Run Cypher Query", + destructiveHint=False, + readOnlyHint=True, + idempotentHint=True, + ), + ) + def _get_graph_schema(self) -> dict[str, Any]: """ Retrieve the PDBe graph schema from the remote server and return it as a dictionary. @@ -276,3 +434,135 @@ def format_example_queries(self) -> str: f"Question: {query.get('description', '')}\nQuery:\n{query.get('query', '')}" for query in self.graph_schema.get("examples", []) ) + + def _get_neo4j_config(self) -> dict[str, str]: + """ + Get Neo4j configuration from environment variables. + + Returns: + Dictionary with neo4j_url, neo4j_username, neo4j_password, and neo4j_database. + + Raises: + RuntimeError: If Neo4j configuration is not available. + """ + config = _get_neo4j_config_from_env() + if not config: + raise RuntimeError( + "Neo4j configuration not found. Please set NEO4J_URL, " + "NEO4J_USERNAME, and NEO4J_PASSWORD environment variables." + ) + return config + + def _get_neo4j_driver(self): + """ + Get a Neo4j driver instance, compatible with both Neo4j 3.5 and 4.x+. + + Neo4j 3.5: Uses `GraphDatabase.driver(url, auth=...)` without database parameter + Neo4j 4.0+: Uses `GraphDatabase.driver(url, auth=..., database=...)` with database parameter + + Returns: + Neo4j Driver instance. + + Raises: + RuntimeError: If Neo4j is not configured or neo4j driver is not installed. + """ + try: + from neo4j import Driver, GraphDatabase + + config = self._get_neo4j_config() + + # Try Neo4j 4+ API first (with database parameter if set) + try: + driver_kwargs = { + "url": config["neo4j_url"], + "auth": (config["neo4j_username"], config["neo4j_password"]), + } + if "neo4j_database" in config: + driver_kwargs["database"] = config["neo4j_database"] + driver = GraphDatabase.driver(**driver_kwargs) + # Driver is lazily validated on first use + return driver + except TypeError as e: + # Neo4j 3.5 doesn't accept 'database' parameter + if "database" not in str(e).lower(): + raise + # Fall back to Neo4j 3.5 API + logger.warning( + "Neo4j 3.5 detected (no database parameter support). " + "Using default database. If this is Neo4j 4+, consider setting NEO4J_DATABASE=neo4j" + ) + return GraphDatabase.driver( + config["neo4j_url"], + auth=(config["neo4j_username"], config["neo4j_password"]), + ) + except ImportError as e: + raise RuntimeError( + "neo4j driver is not installed. Please install it with: pip install neo4j" + ) from e + except Exception as e: + raise RuntimeError(f"Failed to create Neo4j driver: {e}") from e + + def execute_cypher_query(self, query: str) -> str: + """ + Execute a Cypher query against the Neo4j database. + + Args: + query: The Cypher query to execute. + + Returns: + Formatted query results as a string. + + Raises: + ValueError: If the query is not read-only. + RuntimeError: If Neo4j is not configured or query execution fails. + """ + # Validate the query first + is_valid, error_message = _validate_cypher_query(query) + if not is_valid: + raise ValueError( + f"Query validation failed: {error_message}. " + "Only MATCH and OPTIONAL MATCH queries are allowed. " + "MERGE, CREATE, DELETE, REMOVE, SET, LOAD CSV, and FOREACH operations are not allowed." + ) + + driver = None + try: + driver = self._get_neo4j_driver() + with driver.session() as session: + result = session.run(query) + records = list(result) + keys = result.keys() if records else [] + + # Convert to list of dictionaries + results = [dict(zip(keys, record.values())) for record in records] + + # Format TOON or JSON output + if _toon_enabled(): + try: + import toon + + result_text = toon.encode(results) + if not isinstance(result_text, str): + result_text = str(result_text) + except Exception as e: + logger.warning( + "TOON encoding failed, falling back to JSON: %s", e + ) + import json + + result_text = "TOON failed; JSON fallback:\n" + json.dumps( + results, indent=2, default=str + ) + else: + import json + + result_text = json.dumps(results, indent=2, default=str) + + return result_text + + except Exception as e: + logger.error("Neo4j query execution failed: %s", e) + raise RuntimeError(f"Neo4j query execution failed: {e}") from e + finally: + if driver: + driver.close() diff --git a/pdbe_mcp_server/server.py b/pdbe_mcp_server/server.py index 06baec5..a022fc3 100644 --- a/pdbe_mcp_server/server.py +++ b/pdbe_mcp_server/server.py @@ -65,12 +65,20 @@ def build_graph_server() -> Server: @graph_server.list_tools() async def list_tools() -> list[types.Tool]: - return [ + tools = [ graph_tools.get_pdbe_graph_nodes_tool(), graph_tools.get_pdbe_graph_edges_tool(), graph_tools.get_pdbe_graph_example_queries_tool(), ] + # Add the cypher query tool only if Neo4j is configured + from pdbe_mcp_server.graph_tools import _neo4j_enabled + + if _neo4j_enabled(): + tools.append(graph_tools.get_pdbe_run_cypher_query_tool()) + + return tools + @graph_server.call_tool() async def call_tool( name: str, arguments: dict[str, Any] @@ -85,6 +93,30 @@ async def call_tool( text=graph_tools.format_example_queries(), type="text" ) ] + elif name == "pdbe_run_cypher_query": + if not graph_tools: + return [ + types.TextContent( + type="text", + text="Cypher query tool not available: Neo4j configuration is missing", + ) + ] + + cypher_query = arguments.get("cypher_query", "") + if not cypher_query: + return [ + types.TextContent( + type="text", text="Error: cypher_query parameter is required" + ) + ] + + try: + result = graph_tools.execute_cypher_query(cypher_query) + return [types.TextContent(type="text", text=result)] + except ValueError as e: + return [types.TextContent(type="text", text=str(e))] + except RuntimeError as e: + return [types.TextContent(type="text", text=str(e))] else: raise ValueError(f"Unknown tool name: {name}") diff --git a/pyproject.toml b/pyproject.toml index 82a1092..bf94570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ target-version = "py310" [dependency-groups] dev = [ + "neo4j>=5.0", "pyright>=1.1.378", "pytest>=8.3.3", "pytest-cov>=6.0.0", diff --git a/tests/test_graph_tools.py b/tests/test_graph_tools.py index 6522108..abccaee 100644 --- a/tests/test_graph_tools.py +++ b/tests/test_graph_tools.py @@ -281,3 +281,90 @@ def test_get_pdbe_graph_example_queries_tool( assert "cypher" in tool.description.lower() assert tool.inputSchema["type"] == "object" assert tool.inputSchema["additionalProperties"] is False + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_get_pdbe_run_cypher_query_tool( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test getting the run Cypher query MCP tool.""" + mock_get.return_value = mock_graph_schema + + tools = GraphTools() + tool = tools.get_pdbe_run_cypher_query_tool() + + assert tool.name == "pdbe_run_cypher_query" + assert tool.description is not None + assert "MATCH" in tool.description or "cypher" in tool.description.lower() + assert tool.inputSchema["type"] == "object" + assert tool.inputSchema["additionalProperties"] is False + assert "cypher_query" in tool.inputSchema.get("properties", {}) + assert "cypher_query" in tool.inputSchema.get("required", []) + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_validate_cypher_query_valid_match( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test query validation with valid MATCH queries.""" + from pdbe_mcp_server.graph_tools import _validate_cypher_query + + valid_queries = [ + "MATCH (s:Structure) RETURN s", + "OPTIONAL MATCH (s:Structure) WHERE s.pdb_id = '1abc' RETURN s", + "MATCH (s:Structure)-[:HAS_LIGAND]->(l:Ligand) RETURN s, l", + "CALL { MATCH (s:Structure) RETURN s } RETURN s", + ] + + for query in valid_queries: + is_valid, error = _validate_cypher_query(query) + assert is_valid, f"Query should be valid: {query}" + assert error is None + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_validate_cypher_query_invalid_merge( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test query validation rejects MERGE queries.""" + from pdbe_mcp_server.graph_tools import _validate_cypher_query + + is_valid, error = _validate_cypher_query("MERGE (s:Structure {id: 1})") + assert not is_valid + assert error is not None + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_validate_cypher_query_invalid_create( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test query validation rejects CREATE queries.""" + from pdbe_mcp_server.graph_tools import _validate_cypher_query + + is_valid, error = _validate_cypher_query( + "CREATE (s:Structure {id: 1}) RETURN s" + ) + assert not is_valid + assert error is not None + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_validate_cypher_query_invalid_delete( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test query validation rejects DELETE queries.""" + from pdbe_mcp_server.graph_tools import _validate_cypher_query + + is_valid, error = _validate_cypher_query( + "MATCH (s:Structure) DELETE s" + ) + assert not is_valid + assert error is not None + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_validate_cypher_query_invalid_set( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test query validation rejects SET queries.""" + from pdbe_mcp_server.graph_tools import _validate_cypher_query + + is_valid, error = _validate_cypher_query( + "MATCH (s:Structure) SET s.title = 'New Title'" + ) + assert not is_valid + assert error is not None diff --git a/uv.lock b/uv.lock index d8cb71c..e4e45da 100644 --- a/uv.lock +++ b/uv.lock @@ -569,6 +569,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "neo4j" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, +] + [[package]] name = "nh3" version = "0.3.2" @@ -635,7 +647,7 @@ wheels = [ [[package]] name = "pdbe-mcp-server" -version = "1.0.2" +version = "1.0.3" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -651,6 +663,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "neo4j" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -675,6 +688,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "neo4j", specifier = ">=5.0" }, { name = "pyright", specifier = ">=1.1.378" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, @@ -925,6 +939,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/a4/2f2def0378b44f913d2d6cb3bc5b1a15267b363937ab1cb9afb07ce2313c/python_toon-0.1.3-py3-none-any.whl", hash = "sha256:a27b0ee4a729e730d1037d0a63eb8b344b3e5a26e3dc9a173067b6c31a868ee6", size = 21797, upload-time = "2025-11-04T09:12:19.443Z" }, ] +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" From 0629fcc561979725416c34ff274da7d8d7ee332d Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 16:25:30 +0000 Subject: [PATCH 07/21] Bump version to 1.0.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bf94570..2ab7095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdbe-mcp-server" -version = "1.0.3" +version = "1.0.4" description = "A Model Context Protocol (MCP) server for accessing PDBe (Protein Data Bank in Europe) structural biology data and services" readme = "README.md" requires-python = ">=3.10" From 04915c6b510acd909f57b7494531711fce4c9bea Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 16:32:01 +0000 Subject: [PATCH 08/21] fixed linting --- pdbe_mcp_server/graph_tools.py | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pdbe_mcp_server/graph_tools.py b/pdbe_mcp_server/graph_tools.py index 984701d..d517572 100644 --- a/pdbe_mcp_server/graph_tools.py +++ b/pdbe_mcp_server/graph_tools.py @@ -467,7 +467,7 @@ def _get_neo4j_driver(self): RuntimeError: If Neo4j is not configured or neo4j driver is not installed. """ try: - from neo4j import Driver, GraphDatabase + from neo4j import GraphDatabase config = self._get_neo4j_config() diff --git a/uv.lock b/uv.lock index e4e45da..d582baa 100644 --- a/uv.lock +++ b/uv.lock @@ -647,7 +647,7 @@ wheels = [ [[package]] name = "pdbe-mcp-server" -version = "1.0.3" +version = "1.0.4" source = { editable = "." } dependencies = [ { name = "anyio" }, From 43a50b0490adb34442299701616179e4a1881839 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 16:34:27 +0000 Subject: [PATCH 09/21] fixed linting --- tests/test_graph_tools.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_graph_tools.py b/tests/test_graph_tools.py index abccaee..a6e9f93 100644 --- a/tests/test_graph_tools.py +++ b/tests/test_graph_tools.py @@ -350,9 +350,7 @@ def test_validate_cypher_query_invalid_delete( """Test query validation rejects DELETE queries.""" from pdbe_mcp_server.graph_tools import _validate_cypher_query - is_valid, error = _validate_cypher_query( - "MATCH (s:Structure) DELETE s" - ) + is_valid, error = _validate_cypher_query("MATCH (s:Structure) DELETE s") assert not is_valid assert error is not None From a2ab936063fa8a7dfd22a9f9c22a1194012ccb0d Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 16:38:19 +0000 Subject: [PATCH 10/21] fixed pyright issue with query type --- pdbe_mcp_server/graph_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pdbe_mcp_server/graph_tools.py b/pdbe_mcp_server/graph_tools.py index d517572..f1ab2f1 100644 --- a/pdbe_mcp_server/graph_tools.py +++ b/pdbe_mcp_server/graph_tools.py @@ -1,7 +1,7 @@ import logging import os import re -from typing import Any +from typing import Any, LiteralString import mcp.types as types from omegaconf import DictConfig @@ -502,7 +502,7 @@ def _get_neo4j_driver(self): except Exception as e: raise RuntimeError(f"Failed to create Neo4j driver: {e}") from e - def execute_cypher_query(self, query: str) -> str: + def execute_cypher_query(self, query: LiteralString) -> str: """ Execute a Cypher query against the Neo4j database. From c51ee60fe155c5fe20b2909639eeecc2e9479996 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 17:33:15 +0000 Subject: [PATCH 11/21] Added Neo4j as main dependency --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2ab7095..e8e01a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "click>=8.1.0", "httpx>=0.27", "mcp", + "neo4j>=5.0", "omegaconf>=2.3.0", "python-toon>=0.1.3", "requests>=2.32.3", @@ -47,7 +48,6 @@ target-version = "py310" [dependency-groups] dev = [ - "neo4j>=5.0", "pyright>=1.1.378", "pytest>=8.3.3", "pytest-cov>=6.0.0", diff --git a/uv.lock b/uv.lock index d582baa..404e7ee 100644 --- a/uv.lock +++ b/uv.lock @@ -654,6 +654,7 @@ dependencies = [ { name = "click" }, { name = "httpx" }, { name = "mcp" }, + { name = "neo4j" }, { name = "omegaconf" }, { name = "python-toon" }, { name = "requests" }, @@ -663,7 +664,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "neo4j" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -679,6 +679,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "mcp" }, + { name = "neo4j", specifier = ">=5.0" }, { name = "omegaconf", specifier = ">=2.3.0" }, { name = "python-toon", specifier = ">=0.1.3" }, { name = "requests", specifier = ">=2.32.3" }, @@ -688,7 +689,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "neo4j", specifier = ">=5.0" }, { name = "pyright", specifier = ">=1.1.378" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, From 87503264fc21b918c57f84bf973a5c36bfecb7ec Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 28 Mar 2026 17:33:44 +0000 Subject: [PATCH 12/21] bumped up version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8e01a5..0c38a13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdbe-mcp-server" -version = "1.0.4" +version = "1.0.5" description = "A Model Context Protocol (MCP) server for accessing PDBe (Protein Data Bank in Europe) structural biology data and services" readme = "README.md" requires-python = ">=3.10" From 016c91c7115d8cf6479d7141dd5f921664c266a3 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Thu, 2 Apr 2026 16:00:51 +0100 Subject: [PATCH 13/21] Updated graph tools to add more flexibility to the cypher syntax check, reusing Neo4j driver --- pdbe_mcp_server/graph_tools.py | 180 +++++++++++++++++++-------------- uv.lock | 2 +- 2 files changed, 107 insertions(+), 75 deletions(-) diff --git a/pdbe_mcp_server/graph_tools.py b/pdbe_mcp_server/graph_tools.py index f1ab2f1..e73ff1c 100644 --- a/pdbe_mcp_server/graph_tools.py +++ b/pdbe_mcp_server/graph_tools.py @@ -1,7 +1,12 @@ import logging import os import re -from typing import Any, LiteralString +from typing import Any + +try: + from typing import LiteralString +except ImportError: + from typing_extensions import LiteralString import mcp.types as types from omegaconf import DictConfig @@ -13,6 +18,26 @@ conf: DictConfig = get_config() +# Pre-compiled regular expressions +_WRITE_PATTERN = re.compile( + r"\b(MERGE|CREATE|DELETE|REMOVE|SET|ADD|LOAD\s+CSV|FOREACH)\b", re.IGNORECASE +) + +_ALLOWED_START_PATTERN = re.compile( + r"^(?:MATCH|OPTIONAL\s+MATCH|CALL\s*\{[^}]*\})", re.IGNORECASE +) + +_WRITE_KEYWORDS = [ + "MERGE", + "CREATE", + "DELETE", + "REMOVE", + "SET", + "ADD", + "LOAD CSV", + "FOREACH", +] + def _get_neo4j_config_from_env() -> dict[str, str] | None: """ @@ -46,12 +71,12 @@ def _neo4j_enabled() -> bool: def _toon_enabled() -> bool: """Check if TOON output is enabled.""" - return os.getenv("TOON_ENABLED", "false").lower() == "true" + return os.getenv("TOON_ENABLED", "").lower() in ("true", "1", "yes") def _validate_cypher_query(query: str) -> tuple[bool, str | None]: """ - Validate a Cypher query to ensure it is read-only (no write, delete, or update operations). + Validate a Cypher query to ensure it is read-only. Args: query: The Cypher query to validate. @@ -59,71 +84,38 @@ def _validate_cypher_query(query: str) -> tuple[bool, str | None]: Returns: Tuple of (is_valid, error_message). If valid, error_message is None. """ - # Normalize the query: remove comments, extra whitespace, convert to uppercase for matching + # Remove comments and normalize whitespace + # //, /* */ normalized = re.sub(r"/\*.*?\*/", "", query, flags=re.DOTALL) + normalized = re.sub(r"//.*$", "", normalized, flags=re.MULTILINE) normalized = re.sub(r"--.*$", "", normalized, flags=re.MULTILINE) - normalized = " ".join(normalized.upper().split()) - - # Cypher keywords that indicate write, delete, or update operations - write_patterns = [ - r"\bMERGE\b", - r"\bCREATE\b", - r"\bDELETE\b", - r"\bREMOVE\b", - r"\bSET\b", - r"\bADD\b", - r"\bREMOVE\b", - r"\bSET\b", - r"\bSET\s+[A-Za-z_][A-Za-z0-9_]*\s+=", - r"\bMATCH\b.*\bMERGE\b", - r"\bMERGE\b.*\bSET\b", - r"\bCREATE\b.*\bSET\b", - r"\bWITH\b.*\bMERGE\b", - r"\bWITH\b.*\bCREATE\b", - r"\bWITH\b.*\bDELETE\b", - r"\bWITH\b.*\bSET\b", - r"\bLOAD\s+CSV\b", - r"\bFOREACH\b", - r"\bREMOVE\b\b", - ] - - for pattern in write_patterns: - if re.search(pattern, normalized): - return ( - False, - f"Query contains potentially destructive operation (detected pattern: {pattern})", - ) + normalized = " ".join(normalized.split()) - # Additional check: allow only MATCH, OPTIONAL MATCH, CALL {MATCH ...}, RETURN - # This is a safer approach - only allow queries that start with these read operations - allowed_starts = [ - r"^(?:MATCH|OPTIONAL\s+MATCH|CALL\s*\{[^}]*\})", - ] - - # Check if query matches allowed patterns - has_allowed_pattern = any(re.search(p, normalized) for p in allowed_starts) - - # Additional check: if query contains write keywords after MATCH, it might be dangerous - # This catches patterns like "MATCH ... RETURN ... MERGE" - write_keywords = ["MERGE", "CREATE", "DELETE", "REMOVE", "SET"] - if has_allowed_pattern: - # Check if any write operation appears after the initial MATCH/MATCH+CALL - parts = re.split(r"\bRETURN\b", normalized, flags=re.IGNORECASE) - if len(parts) > 1: - # Everything after RETURN is part of RETURN clause, check the rest - pre_return = parts[0] - for keyword in write_keywords: - if re.search(rf"\b{keyword}\b", pre_return, re.IGNORECASE): - return ( - False, - f"Query contains potentially destructive operation ({keyword}) after MATCH", - ) - elif not has_allowed_pattern: + # Check for any write operations + if _WRITE_PATTERN.search(normalized): return ( False, - "Query does not start with allowed read operation (MATCH, OPTIONAL MATCH, or CALL)", + "Query contains potentially destructive operation", ) + # Verify query starts with allowed read operation + if not _ALLOWED_START_PATTERN.search(normalized): + return ( + False, + "Query must start with MATCH, OPTIONAL MATCH, or CALL", + ) + + # Additional check: ensure no write operations after RETURN + if "RETURN" in normalized.upper(): + return_parts = re.split(r"\bRETURN\b", normalized, flags=re.IGNORECASE) + pre_return = return_parts[0] + for keyword in _WRITE_KEYWORDS: + if re.search(rf"\b{keyword}\b", pre_return, re.IGNORECASE): + return ( + False, + f"Query contains destructive operation '{keyword}' before RETURN", + ) + return True, None @@ -132,15 +124,26 @@ class GraphTools: A class to handle PDBe graph-related operations. """ + WRITE_OPERATIONS = frozenset(_WRITE_KEYWORDS) + def __init__(self) -> None: """ Initialize the GraphTools object, load the graph schema, and prepare node and edge lists. + Also initializes the Neo4j driver for reuse across all tool calls. """ self.graph_schema: dict[str, Any] = self._get_graph_schema() self.node_dict: dict[Any, str] = {} self.nodes: list[dict[str, Any]] = self.get_nodes() self.edges: list[dict[str, Any]] = self.get_edges() + # Initialize Neo4j driver once for reuse + self._neo4j_driver: Any = None + + @property + def neo4j_enabled(self) -> bool: + """Check if Neo4j is configured.""" + return _neo4j_enabled() + def get_pdbe_graph_nodes_tool(self) -> types.Tool: return types.Tool( name="pdbe_graph_nodes", @@ -275,7 +278,7 @@ def get_pdbe_run_cypher_query_tool(self) -> types.Tool: "properties": { "cypher_query": { "type": "string", - "description": "The Cypher query to execute. Only MATCH and OPTIONAL MATCH queries are allowed. MERGE, CREATE, DELETE, REMOVE, SET, LOAD CSV, and FOREACH operations are not permitted.", + "description": "The Cypher query to execute. Only read-only operations are allowed.", } }, "required": ["cypher_query"], @@ -294,14 +297,16 @@ def _get_graph_schema(self) -> dict[str, Any]: Retrieve the PDBe graph schema from the remote server and return it as a dictionary. Supports both HTTP(S) URLs and local file paths. """ - if conf.graph.schema_url.startswith("file://"): - file_path = conf.graph.schema_url[len("file://") :] + schema_url = conf.graph.schema_url + + if schema_url.startswith("file://"): + file_path = schema_url[len("file://") :] with open(file_path, "r", encoding="utf-8") as f: import json return json.load(f) else: - return HTTPClient.get(str(conf.graph.schema_url)) + return HTTPClient.get(str(schema_url)) def get_nodes(self) -> list[dict[str, Any]]: """ @@ -321,7 +326,8 @@ def get_nodes(self) -> list[dict[str, Any]]: nodes.append(node) # store the node label in a dictionary for quick access - self.node_dict[node.get("id")] = node["label"] + if node.get("id"): + self.node_dict[node["id"]] = node.get("label", "Unknown") return nodes @@ -453,9 +459,10 @@ def _get_neo4j_config(self) -> dict[str, str]: ) return config - def _get_neo4j_driver(self): + def _get_neo4j_driver(self) -> Any: """ Get a Neo4j driver instance, compatible with both Neo4j 3.5 and 4.x+. + This driver is reused across all tool calls to improve performance. Neo4j 3.5: Uses `GraphDatabase.driver(url, auth=...)` without database parameter Neo4j 4.0+: Uses `GraphDatabase.driver(url, auth=..., database=...)` with database parameter @@ -466,6 +473,9 @@ def _get_neo4j_driver(self): Raises: RuntimeError: If Neo4j is not configured or neo4j driver is not installed. """ + if self._neo4j_driver is not None: + return self._neo4j_driver + try: from neo4j import GraphDatabase @@ -479,9 +489,9 @@ def _get_neo4j_driver(self): } if "neo4j_database" in config: driver_kwargs["database"] = config["neo4j_database"] - driver = GraphDatabase.driver(**driver_kwargs) - # Driver is lazily validated on first use - return driver + + self._neo4j_driver = GraphDatabase.driver(**driver_kwargs) + return self._neo4j_driver except TypeError as e: # Neo4j 3.5 doesn't accept 'database' parameter if "database" not in str(e).lower(): @@ -491,10 +501,11 @@ def _get_neo4j_driver(self): "Neo4j 3.5 detected (no database parameter support). " "Using default database. If this is Neo4j 4+, consider setting NEO4J_DATABASE=neo4j" ) - return GraphDatabase.driver( + self._neo4j_driver = GraphDatabase.driver( config["neo4j_url"], auth=(config["neo4j_username"], config["neo4j_password"]), ) + return self._neo4j_driver except ImportError as e: raise RuntimeError( "neo4j driver is not installed. Please install it with: pip install neo4j" @@ -563,6 +574,27 @@ def execute_cypher_query(self, query: LiteralString) -> str: except Exception as e: logger.error("Neo4j query execution failed: %s", e) raise RuntimeError(f"Neo4j query execution failed: {e}") from e - finally: - if driver: - driver.close() + + def close(self): + """ + Close the Neo4j driver connection. + """ + if self._neo4j_driver is not None: + try: + self._neo4j_driver.close() + except Exception: + logger.warning("Error closing Neo4j driver", exc_info=True) + finally: + self._neo4j_driver = None + + def __del__(self) -> None: + """Cleanup on destruction.""" + self.close() + + def __enter__(self) -> "GraphTools": + """Enter context manager.""" + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Exit context manager, ensuring cleanup.""" + self.close() diff --git a/uv.lock b/uv.lock index ac3ccf0..5adf035 100644 --- a/uv.lock +++ b/uv.lock @@ -647,7 +647,7 @@ wheels = [ [[package]] name = "pdbe-mcp-server" -version = "1.0.4" +version = "1.0.5" source = { editable = "." } dependencies = [ { name = "anyio" }, From eec580bdbb03421a7d018b2532d339a63c5597d1 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Fri, 3 Apr 2026 14:49:19 +0100 Subject: [PATCH 14/21] Add remote server entry point with HTTP interface --- pdbe_mcp_server/remote_server.py | 186 +++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 187 insertions(+) create mode 100644 pdbe_mcp_server/remote_server.py diff --git a/pdbe_mcp_server/remote_server.py b/pdbe_mcp_server/remote_server.py new file mode 100644 index 0000000..44b44f6 --- /dev/null +++ b/pdbe_mcp_server/remote_server.py @@ -0,0 +1,186 @@ +import contextlib +import logging +import os +import sys + +import click +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Mount, Route +from starlette.types import Receive, Scope, Send + +from pdbe_mcp_server.server import ( + build_graph_server, + build_pdbe_api_server, + build_pdbe_search_server, +) + +logger = logging.getLogger(__name__) +ROOT_PREFIX = os.getenv("ROOT_PREFIX", "") + + +# ------------------------- +# Helpers +# ------------------------- +def parse_cors_origins(origins_str: str) -> list[str]: + if origins_str == "*": + return ["*"] + return [o.strip() for o in origins_str.split(",") if o.strip()] or ["*"] + + +async def handle_health(scope: Scope, receive: Receive, send: Send) -> None: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [("content-type", "application/json")], + } + ) + await send({"type": "http.response.body", "body": b'{"status": "healthy"}'}) + + +async def handle_ready(scope: Scope, receive: Receive, send: Send) -> None: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [("content-type", "application/json")], + } + ) + await send({"type": "http.response.body", "body": b'{"status": "ready"}'}) + + +# ------------------------- +# App Factory +# ------------------------- +def create_app() -> Starlette: + log_level_str = os.getenv("LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, log_level_str, logging.INFO) + + json_response = os.getenv("JSON_RESPONSE", "false").lower() == "true" + cors_origins_str = os.getenv("CORS_ORIGINS", "*") + allowed_origins = parse_cors_origins(cors_origins_str) + + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # Build backend servers + graph_server = build_graph_server() + search_server = build_pdbe_search_server() + api_server = build_pdbe_api_server() + + session_managers = { + "graph": StreamableHTTPSessionManager( + app=graph_server, + event_store=None, + json_response=json_response, + stateless=True, + ), + "search": StreamableHTTPSessionManager( + app=search_server, + event_store=None, + json_response=json_response, + stateless=True, + ), + "api": StreamableHTTPSessionManager( + app=api_server, + event_store=None, + json_response=json_response, + stateless=True, + ), + } + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + for name, manager in session_managers.items(): + await stack.enter_async_context(manager.run()) + logger.info(f"Started session manager for '{name}'") + yield + logger.info("Shutting down...") + + def create_handler(manager: StreamableHTTPSessionManager): + async def handler(scope: Scope, receive: Receive, send: Send): + try: + await manager.handle_request(scope, receive, send) + except Exception as e: + logger.error(f"Handler error: {e}") + await send( + { + "type": "http.response.start", + "status": 500, + "headers": [("content-type", "application/json")], + } + ) + await send( + { + "type": "http.response.body", + "body": b'{"error": "Internal server error"}', + } + ) + + return handler + + routes = [ + Mount(f"{ROOT_PREFIX}/graph", app=create_handler(session_managers["graph"])), + Mount(f"{ROOT_PREFIX}/search", app=create_handler(session_managers["search"])), + Mount(f"{ROOT_PREFIX}/api", app=create_handler(session_managers["api"])), + Route(f"{ROOT_PREFIX}/health", endpoint=handle_health), + Route(f"{ROOT_PREFIX}/ready", endpoint=handle_ready), + ] + + app = Starlette( + debug=(log_level == logging.DEBUG), + routes=routes, + lifespan=lifespan, + ) + + return CORSMiddleware( + app, + allow_origins=allowed_origins, + allow_methods=["GET", "POST", "DELETE"], + expose_headers=["Mcp-Session-Id"], + ) + + +starlette_app = create_app() + + +@click.command() +@click.option("--host", default="127.0.0.1") +@click.option("--port", default=8000) +@click.option("--log-level", default="INFO") +@click.option("--json-response", is_flag=True, default=False) +@click.option("--cors-origins", default="*") +@click.option("--workers", default=1, type=int) +@click.option("--reload", is_flag=True, default=False) +def main(host, port, log_level, json_response, cors_origins, workers, reload): + # Validate combination + if reload and workers > 1: + raise click.UsageError("Cannot use --reload with multiple workers") + + # Pass config via environment + os.environ["LOG_LEVEL"] = log_level + os.environ["JSON_RESPONSE"] = str(json_response).lower() + os.environ["CORS_ORIGINS"] = cors_origins + + import uvicorn + + logger.info(f"Starting server on http://{host}:{port}") + logger.info(f"Config: workers={workers}, reload={reload}, log_level={log_level}") + + uvicorn.run( + "pdbe_mcp_server.remote_server:starlette_app", + host=host, + port=port, + workers=workers if not reload else 1, + reload=reload, + log_level=log_level.lower(), + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index f74dc49..fe091b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ [project.scripts] pdbe-mcp-server = "pdbe_mcp_server.server:main" +pdbe-mcp-remote-server = "pdbe_mcp_server.remote_server:main" [build-system] requires = ["hatchling"] From dc0598a92f5e417a49aff7468719535b3d7005db Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Fri, 3 Apr 2026 14:56:07 +0100 Subject: [PATCH 15/21] Refactor Dockerfile to run single MCP server via uvicorn --- Dockerfile | 26 +++++---------- nginx.conf | 97 ------------------------------------------------------ start.sh | 10 ------ 3 files changed, 9 insertions(+), 124 deletions(-) delete mode 100644 nginx.conf delete mode 100644 start.sh diff --git a/Dockerfile b/Dockerfile index 820ae5a..d35386c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,11 @@ -# Multi-service container: runs 3 MCP SSE servers and Nginx reverse proxy +# Multi-service container: runs MCP remote server with uvicorn FROM python:3.11-slim -# Install system packages: nginx and curl (for healthcheck) +# Install system packages: curl (for healthcheck) RUN apt-get update \ - && apt-get install -y --no-install-recommends nginx curl \ + && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* -# Prepare nginx runtime dir -RUN mkdir -p /var/run/nginx - # Set workdir and copy app source WORKDIR /app COPY . /app @@ -16,16 +13,11 @@ COPY . /app # Install Python dependencies from project RUN pip install --no-cache-dir . -# Copy Nginx configuration (standalone version with localhost) -COPY nginx.conf /etc/nginx/nginx.conf -COPY start.sh /start.sh -RUN chmod +x /start.sh - -# Expose Nginx port -EXPOSE 8080 +# Expose port for MCP server +EXPOSE 8000 -# Healthcheck: ensure Nginx responds -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://localhost:8080/health || exit 1 +# Healthcheck: ensure server responds +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://localhost:8000/health || exit 1 -# Run all services via start script (uvicorn x3 + nginx) -CMD ["/start.sh"] +# Run MCP remote server with uvicorn +CMD ["python", "-m", "pdbe_mcp_server.remote_server", "--host", "0.0.0.0", "--port", "8000"] diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 4e9431d..0000000 --- a/nginx.conf +++ /dev/null @@ -1,97 +0,0 @@ -worker_processes auto; - -events { - worker_connections 1024; -} - -http { - include mime.types; - default_type application/octet-stream; - sendfile on; - keepalive_timeout 65; - - map $request_method $is_options { - default 0; - "OPTIONS" 1; - } - - # Simple health endpoint - server { - listen 8080; - server_name _; - - # Health check - location = /health { - add_header Content-Type text/plain; - return 200 'ok\n'; - } - - - # API server - proxy entire /api prefix - location /api/ { - - # CORS - if ($is_options) { - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; - add_header Access-Control-Allow-Headers '*'; - return 204; - } - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; - add_header Access-Control-Allow-Headers '*'; - - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Connection ''; - proxy_buffering off; # critical for SSE - add_header X-Accel-Buffering no; # disable nginx buffering - - proxy_pass http://127.0.0.1:8010/; - } - - # Graph server - proxy entire /graph prefix - location /graph/ { - - if ($is_options) { - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; - add_header Access-Control-Allow-Headers '*'; - return 204; - } - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; - add_header Access-Control-Allow-Headers '*'; - - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Connection ''; - proxy_buffering off; - add_header X-Accel-Buffering no; - - proxy_pass http://127.0.0.1:8020/; - } - - # Search server - proxy entire /search prefix - location /search/ { - - if ($is_options) { - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; - add_header Access-Control-Allow-Headers '*'; - return 204; - } - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; - add_header Access-Control-Allow-Headers '*'; - - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Connection ''; - proxy_buffering off; - add_header X-Accel-Buffering no; - - proxy_pass http://127.0.0.1:8030/; - } - } -} diff --git a/start.sh b/start.sh deleted file mode 100644 index 7ca3e0d..0000000 --- a/start.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -euo pipefail - -# Launch three MCP servers in SSE mode on distinct ports -pdbe-mcp-server --transport sse --server-type pdbe_api_server --port 8010 & -pdbe-mcp-server --transport sse --server-type pdbe_graph_server --port 8020 & -pdbe-mcp-server --transport sse --server-type pdbe_search_server --port 8030 & - -# Start nginx in foreground -exec nginx -g 'daemon off;' From 325637e6c2d376caf2f50559db6b5e661e160770 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sun, 5 Apr 2026 14:44:55 +0100 Subject: [PATCH 16/21] Add PDBe graph indexes tool and refactor health checks Refactor Dockerfile to use uv for dependency management and build optimization. Update Neo4j environment variable name from NEO4J_URL to NEO4J_URI across config and documentation. Introduce new tool to expose database indexes for query optimization. Simplify health and ready check handlers to use Starlette responses. --- .dockerignore | 76 ++++++++++++++++ Dockerfile | 33 +++---- README.md | 2 +- pdbe_mcp_server/graph_tools.py | 72 ++++++++++++++- pdbe_mcp_server/remote_server.py | 25 ++---- pdbe_mcp_server/server.py | 3 + tests/test_graph_tools.py | 147 +++++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 38 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49cf7ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,76 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing and coverage +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover +*.py,cover +tests/ + +# Linting and formatting +.ruff_cache/ +.mypy_cache/ +.pyright/ + +# Build tools +.hatch/ +.hypothesis/ + +# Documentation +docs/_build/ + +# CI/CD +.github/ +.gitlab-ci.yml + +# Config files +.env +.env.* +*.env + +# Notes and temporary files +notes.md +demo.md +*.md.backup + +# Development docs +DEVELOPMENT.md +CONTRIBUTING.md diff --git a/Dockerfile b/Dockerfile index d35386c..752dc30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,26 @@ -# Multi-service container: runs MCP remote server with uvicorn +# Single-stage Dockerfile for MCP remote server using uv FROM python:3.11-slim -# Install system packages: curl (for healthcheck) -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* +# Install uv +RUN pip install uv -# Set workdir and copy app source WORKDIR /app -COPY . /app -# Install Python dependencies from project -RUN pip install --no-cache-dir . +# Copy only necessary files first for better caching +COPY pyproject.toml README.md ./ +COPY pdbe_mcp_server/py.typed ./pdbe_mcp_server/py.typed -# Expose port for MCP server -EXPOSE 8000 +# Create a virtual environment +RUN uv venv + +# Install dependencies using uv pip install +RUN uv pip install --no-cache-dir . -# Healthcheck: ensure server responds -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://localhost:8000/health || exit 1 +# Copy application source +COPY pdbe_mcp_server/ ./pdbe_mcp_server/ + +# Expose port +EXPOSE 8000 -# Run MCP remote server with uvicorn -CMD ["python", "-m", "pdbe_mcp_server.remote_server", "--host", "0.0.0.0", "--port", "8000"] +# Run with uv +CMD ["uv", "run", "pdbe-mcp-remote-server", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 8269afb..dac74af 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ uv run pdbe-mcp-server --server-type pdbe_graph_server --transport sse This server supports executing custom Cypher queries against a Neo4j graph database. The `pdbe_run_cypher_query` tool is only available when the following environment variables are set: -- `NEO4J_URL`: The Neo4j database URL (e.g., `bolt://localhost:7687`) +- `NEO4J_URI`: The Neo4j database URL (e.g., `bolt://localhost:7687`) - `NEO4J_USERNAME`: The Neo4j username - `NEO4J_PASSWORD`: The Neo4j password - `NEO4J_DATABASE` (optional): The database name. When set, this is passed to the Neo4j driver for Neo4j 4+. For Neo4j 3.5 compatibility, omit this variable to use the default database. diff --git a/pdbe_mcp_server/graph_tools.py b/pdbe_mcp_server/graph_tools.py index e73ff1c..1b4d0c2 100644 --- a/pdbe_mcp_server/graph_tools.py +++ b/pdbe_mcp_server/graph_tools.py @@ -47,14 +47,14 @@ def _get_neo4j_config_from_env() -> dict[str, str] | None: Dictionary with neo4j_url, neo4j_username, neo4j_password, and neo4j_database, or None if not all required variables are set. """ - neo4j_url = os.getenv("NEO4J_URL") + neo4j_uri = os.getenv("NEO4J_URI") neo4j_username = os.getenv("NEO4J_USERNAME") neo4j_password = os.getenv("NEO4J_PASSWORD") neo4j_database = os.getenv("NEO4J_DATABASE") - if neo4j_url and neo4j_username and neo4j_password: + if neo4j_uri and neo4j_username and neo4j_password: config = { - "neo4j_url": neo4j_url, + "neo4j_url": neo4j_uri, "neo4j_username": neo4j_username, "neo4j_password": neo4j_password, } @@ -252,6 +252,47 @@ def get_pdbe_graph_example_queries_tool(self) -> types.Tool: ), ) + def get_pdbe_graph_indexes_tool(self) -> types.Tool: + """ + Tool to retrieve all indexes defined in the PDBe graph database schema. + This helps in writing efficient Cypher queries by knowing which properties are indexed. + """ + return types.Tool( + name="pdbe_graph_indexes", + description=""" + Retrieves metadata about all indexes defined in the PDBe (PDBe-KB) Neo4j graph database schema. + This tool can be used to understand which node properties are indexed in the graph database, which is helpful + when writing Cypher queries to ensure indexes are properly utilized for optimal performance. + + This tool returns detailed information about each index in the graph database. For every index, it includes: + - The node label that the index applies to (e.g., 'Entry', 'Pfam', 'UniProt') + - The property name that is indexed (e.g., 'ID', 'PFAM_ACCESSION', 'ACCESSION') + + Expected Output Format (text): + Node: Entry + Property: ID + + Node: Pfam + Property: PFAM_ACCESSION + + Node: UniProt + Property: ACCESSION + + (Additional indexes follow the same format...) + """, + inputSchema={ + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + annotations=types.ToolAnnotations( + title="Get PDBe Graph Indexes", + destructiveHint=False, + readOnlyHint=True, + idempotentHint=True, + ), + ) + def get_pdbe_run_cypher_query_tool(self) -> types.Tool: return types.Tool( name="pdbe_run_cypher_query", @@ -441,6 +482,31 @@ def format_example_queries(self) -> str: for query in self.graph_schema.get("examples", []) ) + def format_indexes(self) -> str: + """ + Format indexes as a string for LLM or human-readable output. + + Returns: + A formatted string listing all indexes defined in the graph database schema. + """ + indexes = self.graph_schema.get("indexes", []) + if not indexes: + return "No indexes defined in the schema." + + return "\n\n".join( + f"Node: {idx.get('node', '')}\nProperty: {idx.get('properties', '')}" + for idx in indexes + ) + + def get_indexes(self) -> list[dict[str, str]]: + """ + Get the indexes from the graph schema. + + Returns: + List of index dictionaries, each containing 'node' and 'properties' keys. + """ + return self.graph_schema.get("indexes", []) + def _get_neo4j_config(self) -> dict[str, str]: """ Get Neo4j configuration from environment variables. diff --git a/pdbe_mcp_server/remote_server.py b/pdbe_mcp_server/remote_server.py index 44b44f6..afddedf 100644 --- a/pdbe_mcp_server/remote_server.py +++ b/pdbe_mcp_server/remote_server.py @@ -7,6 +7,7 @@ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse from starlette.routing import Mount, Route from starlette.types import Receive, Scope, Send @@ -29,26 +30,12 @@ def parse_cors_origins(origins_str: str) -> list[str]: return [o.strip() for o in origins_str.split(",") if o.strip()] or ["*"] -async def handle_health(scope: Scope, receive: Receive, send: Send) -> None: - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [("content-type", "application/json")], - } - ) - await send({"type": "http.response.body", "body": b'{"status": "healthy"}'}) +async def handle_health(request) -> JSONResponse: + return JSONResponse({"status": "healthy"}) -async def handle_ready(scope: Scope, receive: Receive, send: Send) -> None: - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [("content-type", "application/json")], - } - ) - await send({"type": "http.response.body", "body": b'{"status": "ready"}'}) +async def handle_ready(request) -> JSONResponse: + return JSONResponse({"status": "ready"}) # ------------------------- @@ -169,7 +156,7 @@ def main(host, port, log_level, json_response, cors_origins, workers, reload): import uvicorn - logger.info(f"Starting server on http://{host}:{port}") + logger.info(f"Starting server on http://{host}{ROOT_PREFIX}:{port}") logger.info(f"Config: workers={workers}, reload={reload}, log_level={log_level}") uvicorn.run( diff --git a/pdbe_mcp_server/server.py b/pdbe_mcp_server/server.py index 3a3102e..674097a 100644 --- a/pdbe_mcp_server/server.py +++ b/pdbe_mcp_server/server.py @@ -72,6 +72,7 @@ async def list_tools() -> list[types.Tool]: graph_tools.get_pdbe_graph_nodes_tool(), graph_tools.get_pdbe_graph_edges_tool(), graph_tools.get_pdbe_graph_example_queries_tool(), + graph_tools.get_pdbe_graph_indexes_tool(), ] # Add the cypher query tool only if Neo4j is configured @@ -96,6 +97,8 @@ async def call_tool( text=graph_tools.format_example_queries(), type="text" ) ] + elif name == "pdbe_graph_indexes": + return [types.TextContent(text=graph_tools.format_indexes(), type="text")] elif name == "pdbe_run_cypher_query": if not graph_tools: return [ diff --git a/tests/test_graph_tools.py b/tests/test_graph_tools.py index a6e9f93..82bd1f1 100644 --- a/tests/test_graph_tools.py +++ b/tests/test_graph_tools.py @@ -366,3 +366,150 @@ def test_validate_cypher_query_invalid_set( ) assert not is_valid assert error is not None + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_get_pdbe_graph_indexes_tool( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test getting the graph indexes MCP tool.""" + schema_with_indexes = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + "indexes": [ + {"node": "Entry", "properties": "ID"}, + {"node": "Pfam", "properties": "PFAM_ACCESSION"}, + {"node": "UniProt", "properties": "ACCESSION"}, + ], + } + mock_get.return_value = schema_with_indexes + + tools = GraphTools() + tool = tools.get_pdbe_graph_indexes_tool() + + assert tool.name == "pdbe_graph_indexes" + assert tool.description is not None + assert "index" in tool.description.lower() + assert "indexed" in tool.description.lower() + assert tool.inputSchema["type"] == "object" + assert tool.inputSchema["additionalProperties"] is False + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_format_indexes_with_data( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test formatting indexes with data.""" + schema_with_indexes = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + "indexes": [ + {"node": "Entry", "properties": "ID"}, + {"node": "Pfam", "properties": "PFAM_ACCESSION"}, + {"node": "UniProt", "properties": "ACCESSION"}, + ], + } + mock_get.return_value = schema_with_indexes + + tools = GraphTools() + formatted = tools.format_indexes() + + assert "Node: Entry" in formatted + assert "Property: ID" in formatted + assert "Node: Pfam" in formatted + assert "Property: PFAM_ACCESSION" in formatted + assert "Node: UniProt" in formatted + assert "Property: ACCESSION" in formatted + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_format_indexes_empty( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test formatting indexes when there are no indexes.""" + schema_with_empty_indexes = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + "indexes": [], + } + mock_get.return_value = schema_with_empty_indexes + + tools = GraphTools() + formatted = tools.format_indexes() + + assert "No indexes defined in the schema." in formatted + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_format_indexes_no_indexes_key( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test formatting indexes when indexes key is missing.""" + mock_get.return_value = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + } + + tools = GraphTools() + formatted = tools.format_indexes() + + assert "No indexes defined in the schema." in formatted + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_get_indexes( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test retrieving indexes from schema.""" + schema_with_indexes = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + "indexes": [ + {"node": "Entry", "properties": "ID"}, + {"node": "Pfam", "properties": "PFAM_ACCESSION"}, + ], + } + mock_get.return_value = schema_with_indexes + + tools = GraphTools() + indexes = tools.get_indexes() + + assert len(indexes) == 2 + assert indexes[0]["node"] == "Entry" + assert indexes[0]["properties"] == "ID" + assert indexes[1]["node"] == "Pfam" + assert indexes[1]["properties"] == "PFAM_ACCESSION" + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_get_indexes_empty( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test retrieving indexes when there are none.""" + schema_with_empty_indexes = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + "indexes": [], + } + mock_get.return_value = schema_with_empty_indexes + + tools = GraphTools() + indexes = tools.get_indexes() + + assert indexes == [] + + @patch("pdbe_mcp_server.graph_tools.HTTPClient.get") + def test_get_indexes_no_key( + self, mock_get: MagicMock, mock_graph_schema: dict[str, Any] + ) -> None: + """Test retrieving indexes when indexes key is missing.""" + mock_get.return_value = { + "nodes": mock_graph_schema["nodes"], + "edges": mock_graph_schema["edges"], + "examples": mock_graph_schema.get("examples", []), + } + + tools = GraphTools() + indexes = tools.get_indexes() + + assert indexes == [] From 8a11dd46f27aa35d2c6dd8af05ddc6c61fb02071 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sun, 12 Apr 2026 10:22:36 +0100 Subject: [PATCH 17/21] Enable async Neo4j driver and query execution --- pdbe_mcp_server/graph_tools.py | 14 +++++++------- pdbe_mcp_server/server.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pdbe_mcp_server/graph_tools.py b/pdbe_mcp_server/graph_tools.py index 1b4d0c2..2c858b5 100644 --- a/pdbe_mcp_server/graph_tools.py +++ b/pdbe_mcp_server/graph_tools.py @@ -543,7 +543,7 @@ def _get_neo4j_driver(self) -> Any: return self._neo4j_driver try: - from neo4j import GraphDatabase + from neo4j import AsyncGraphDatabase config = self._get_neo4j_config() @@ -556,7 +556,7 @@ def _get_neo4j_driver(self) -> Any: if "neo4j_database" in config: driver_kwargs["database"] = config["neo4j_database"] - self._neo4j_driver = GraphDatabase.driver(**driver_kwargs) + self._neo4j_driver = AsyncGraphDatabase.driver(**driver_kwargs) return self._neo4j_driver except TypeError as e: # Neo4j 3.5 doesn't accept 'database' parameter @@ -567,7 +567,7 @@ def _get_neo4j_driver(self) -> Any: "Neo4j 3.5 detected (no database parameter support). " "Using default database. If this is Neo4j 4+, consider setting NEO4J_DATABASE=neo4j" ) - self._neo4j_driver = GraphDatabase.driver( + self._neo4j_driver = AsyncGraphDatabase.driver( config["neo4j_url"], auth=(config["neo4j_username"], config["neo4j_password"]), ) @@ -579,7 +579,7 @@ def _get_neo4j_driver(self) -> Any: except Exception as e: raise RuntimeError(f"Failed to create Neo4j driver: {e}") from e - def execute_cypher_query(self, query: LiteralString) -> str: + async def execute_cypher_query(self, query: LiteralString) -> str: """ Execute a Cypher query against the Neo4j database. @@ -605,9 +605,9 @@ def execute_cypher_query(self, query: LiteralString) -> str: driver = None try: driver = self._get_neo4j_driver() - with driver.session() as session: - result = session.run(query) - records = list(result) + async with driver.session() as session: + result = await session.run(query) + records = await result.data() keys = result.keys() if records else [] # Convert to list of dictionaries diff --git a/pdbe_mcp_server/server.py b/pdbe_mcp_server/server.py index 674097a..7a15eeb 100644 --- a/pdbe_mcp_server/server.py +++ b/pdbe_mcp_server/server.py @@ -117,7 +117,7 @@ async def call_tool( ] try: - result = graph_tools.execute_cypher_query(cypher_query) + result = await graph_tools.execute_cypher_query(cypher_query) return [types.TextContent(type="text", text=result)] except ValueError as e: return [types.TextContent(type="text", text=str(e))] From 3cfdc7fb6a4673e552b230cd35a7db5be939d526 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 18 Apr 2026 10:59:40 +0100 Subject: [PATCH 18/21] Add automatic wildcard and case handling for PDBe search Automatically convert search queries into case-insensitive wildcard searches on the `text` field. This allows users to pass raw input strings without needing to specify the field or use wildcards themselves. Escapes Solr special characters to prevent syntax errors. --- pdbe_mcp_server/search_tools.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/pdbe_mcp_server/search_tools.py b/pdbe_mcp_server/search_tools.py index c65217a..d6b8eb3 100644 --- a/pdbe_mcp_server/search_tools.py +++ b/pdbe_mcp_server/search_tools.py @@ -17,8 +17,10 @@ def get_run_search_query_tool(self) -> types.Tool: description=""" Executes a search query against the PDBe Solr search service. This tool allows users to perform search queries on the PDBe database using Solr's querying capabilities. Users can specify various parameters to refine their search and retrieve relevant results. + IMPORTANT: the `text` field is a copy field that contains the full searchable text aggregated from the document. By default, LLMs should use the `text` field for any search unless there is a strong reason to target a different field. + IMPORTANT: search queries should always be treated as case-insensitive wildcard searches on the `text` field. The backend will normalize the input into the form `text:**`, so a user query like `1cbs` will search as `text:*1cbs*`. Expected Input Parameters: - - query (string): The search query string to be executed. + - query (string): The search text to be executed. This will always be converted into a case-insensitive wildcard query on the `text` field. - filters (list of strings, optional): A list of filter queries to narrow down the search results. - sort (string, optional): The sorting criteria for the search results. - start (integer, optional): The starting index for pagination of results. @@ -26,7 +28,7 @@ def get_run_search_query_tool(self) -> types.Tool: Example Input: { - "query": "pdb_id:1cbs", + "query": "1cbs", "filters": ["deposition_date"], "sort": "deposition_date desc", "start": 0, @@ -62,6 +64,23 @@ def get_run_search_query_tool(self) -> types.Tool: ), ) + @staticmethod + def _build_text_wildcard_query(query: str) -> str: + query = query.strip().lower() + if not query: + return "text:*" + + # Escape Solr special characters in the user input before applying the + # wildcard pattern on the `text` copy field. + escaped_query = [] + for char in query: + if char in r'+-&|!(){}[]^"~?:\/': + escaped_query.append(f"\\{char}") + else: + escaped_query.append(char) + + return f"text:*{''.join(escaped_query)}*" + def get_search_schema_tool(self) -> types.Tool: return types.Tool( name="get_search_schema", @@ -101,7 +120,7 @@ def get_search_schema(self) -> str: return "\n".join(content) def run_search_query(self, arguments: dict[str, Any]) -> str: - query = arguments.get("query", "") + query = self._build_text_wildcard_query(arguments.get("query", "")) filters = arguments.get("filters", []) sort = arguments.get("sort", None) start = arguments.get("start", 0) From 5ecbac802587813617f92e1e10ea0336a57ed724 Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 18 Apr 2026 11:02:01 +0100 Subject: [PATCH 19/21] Bump version to 1.0.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f74dc49..06a6ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdbe-mcp-server" -version = "1.0.5" +version = "1.0.6" description = "A Model Context Protocol (MCP) server for accessing PDBe (Protein Data Bank in Europe) structural biology data and services" readme = "README.md" requires-python = ">=3.10" From 38a8cc7308f1959fe0517daf8f85b0ce927b7bdb Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 18 Apr 2026 11:05:24 +0100 Subject: [PATCH 20/21] Update test expectation to use escaped query syntax --- tests/test_search_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_search_tools.py b/tests/test_search_tools.py index e8d711f..e9c39ab 100644 --- a/tests/test_search_tools.py +++ b/tests/test_search_tools.py @@ -100,7 +100,7 @@ def test_run_search_query_with_filters( call_args = mock_get.call_args assert call_args is not None params = call_args.kwargs["params"] - assert params["q"] == "pdb_id:1cbs" + assert params["q"] == r"text:*pdb_id\:1cbs*" assert params["fl"] == "pdb_id,title" assert params["rows"] == "10" From 25c1d6f276c7bc1d6f2fe5be0318d35c9576495b Mon Sep 17 00:00:00 2001 From: Sreenath Nair Date: Sat, 18 Apr 2026 11:05:29 +0100 Subject: [PATCH 21/21] Bump pdbe-mcp-server version to 1.0.6 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index ac3ccf0..7e7b88b 100644 --- a/uv.lock +++ b/uv.lock @@ -647,7 +647,7 @@ wheels = [ [[package]] name = "pdbe-mcp-server" -version = "1.0.4" +version = "1.0.6" source = { editable = "." } dependencies = [ { name = "anyio" },