Repository: kbase/cdm-mcp-server
Pull request #15: use sqlparse to check query
Author: login: Tianhao-Gu
Created: 2025-05-09T16:35:28Z | Merged: 2025-05-13T21:01:35Z



Diff:
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 36bc51e..a129c5d 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -149,8 +149,11 @@ services:
       - POSTGRES_USER=hive
       - POSTGRES_PASSWORD=hivepassword
       - POSTGRES_DB=hive
+      - READONLY_USER=readonly_user
+      - READONLY_PASSWORD=readonly_password
     volumes:
       - ./cdr/cdm/jupyter/cdm-postgres/data:/var/lib/postgresql/data
+      - ./scripts/init-postgres-readonly.sh:/docker-entrypoint-initdb.d/init-postgres-readonly.sh
     networks:
       - cdm-jupyterhub-network
   
@@ -169,8 +172,8 @@ services:
       - KBASE_AUTH_URL=https://ci.kbase.us/services/auth/
       - KBASE_ADMIN_ROLES=CDM_JUPYTERHUB_ADMIN
       - SPARK_DRIVER_HOST=cdm-mcp-server
-      - POSTGRES_USER=hive
-      - POSTGRES_PASSWORD=hivepassword
+      - POSTGRES_USER=readonly_user
+      - POSTGRES_PASSWORD=readonly_password
       - POSTGRES_DB=hive
       - POSTGRES_URL=postgres:5432
       - SPARK_POOL=highPriority
diff --git a/requirements.txt b/requirements.txt
index d5e00bc..2e3fe5f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,4 +8,5 @@ cacheout==0.16.0
 minio==7.2.15
 pytest==8.3.5
 pytest-cov==6.1.1
-psycopg==3.2.7
\ No newline at end of file
+psycopg==3.2.7
+sqlparse==0.5.3
\ No newline at end of file
diff --git a/scripts/init-postgres-readonly.sh b/scripts/init-postgres-readonly.sh
new file mode 100755
index 0000000..78b97b5
--- /dev/null
+++ b/scripts/init-postgres-readonly.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
+  CREATE USER $READONLY_USER WITH PASSWORD '$READONLY_PASSWORD';
+  GRANT CONNECT ON DATABASE $POSTGRES_DB TO $READONLY_USER;
+  GRANT USAGE ON SCHEMA public TO $READONLY_USER;
+  GRANT SELECT ON ALL TABLES IN SCHEMA public TO $READONLY_USER;
+  ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO $READONLY_USER;
+EOSQL
\ No newline at end of file
diff --git a/scripts/mcp-server-entrypoint.sh b/scripts/mcp-server-entrypoint.sh
old mode 100644
new mode 100755
diff --git a/src/delta_lake/delta_service.py b/src/delta_lake/delta_service.py
index 9e173fe..4d1a805 100644
--- a/src/delta_lake/delta_service.py
+++ b/src/delta_lake/delta_service.py
@@ -5,6 +5,7 @@
 import logging
 from typing import Any, Dict, List
 
+import sqlparse
 from pyspark.sql import SparkSession
 
 from src.delta_lake.data_store import database_exists, table_exists
@@ -12,6 +13,7 @@
     DeltaDatabaseNotFoundError,
     DeltaTableNotFoundError,
     SparkOperationError,
+    SparkQueryError,
 )
 
 logger = logging.getLogger(__name__)
@@ -20,6 +22,8 @@
 
 # Common SQL keywords that might indicate destructive operations
 FORBIDDEN_KEYWORDS = {
+    # NOTE: This might create false positives, legitemate queries might include these keywords 
+    # e.g. "SELECT * FROM orders ORDER BY created_at DESC"
     "drop",
     "truncate",
     "delete",
@@ -33,6 +37,70 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    # NOTE: This might create false positives, legitemate queries might include these schemas
+    # e.g. "SELECT * FROM jpg_files"
+    # NOTE: may also need to expand this if other databases are used
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+
+    Please note that this function is not a comprehensive SQL query validator.
+    It only checks for basic syntax and structure.
+    MCP server should be configured to use read-only user for both PostgreSQL and MinIO.
+    """
+
+    try:
+        # NOTE: sqlparse does not validate SQL syntax; what happens with unexpected syntax is unknown
+        statements = sqlparse.parse(query)
+    except Exception as e:
+        raise SparkQueryError(f"Query {query} is not a valid SQL query: {e}")
+
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    # NOTE: statement might have subqueries, we only check the main statement here!
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
+
+    if any(schema in query.lower() for schema in FORBIDDEN_POSTGRESQL_SCHEMAS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden PostgreSQL schema: {', '.join(FORBIDDEN_POSTGRESQL_SCHEMAS)}"
+        )
+
+    if any(char in query for char in DISALLOW_SQL_META_CHARS):
+        raise SparkQueryError(
+            f"Query {query} contains disallowed metacharacter: {', '.join(char for char in DISALLOW_SQL_META_CHARS if char in query)}"
+        )
+
+    if any(keyword in query.lower() for keyword in FORBIDDEN_KEYWORDS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden keyword: {', '.join(keyword for keyword in FORBIDDEN_KEYWORDS if keyword in query.lower())}"
+        )
+
+    return True
+
 
 def _check_exists(database: str, table: str) -> bool:
     """
@@ -107,13 +175,8 @@ def sample_delta_table(
         if columns:
             df = df.select(columns)
         if where_clause:
-            for keyword in FORBIDDEN_KEYWORDS:
-                if keyword in where_clause.lower():
-                    raise ValueError(
-                        f"Filter expression contains forbidden keyword: {keyword}"
-                    )
-            # TODO: build additional validation for query to ensure it is read only
-
+            equivalent_query = f"SELECT * FROM {full_table_name} WHERE {where_clause}"
+            _check_query_is_valid(equivalent_query)
             df = df.filter(where_clause)
 
         df = df.limit(limit)
@@ -140,11 +203,7 @@ def query_delta_table(spark: SparkSession, query: str) -> List[Dict[str, Any]]:
         A list of dictionaries, where each dictionary represents a row.
     """
 
-    for keyword in FORBIDDEN_KEYWORDS:
-        if keyword in query.lower():
-            raise ValueError(f"Query contains forbidden keyword: {keyword}")
-
-    # TODO: build additional validation for query to ensure it is read only
+    _check_query_is_valid(query)
 
     logger.info(f"Executing validated query: {query}")
     try:
diff --git a/src/main.py b/src/main.py
index 15ec9cd..bab84b7 100644
--- a/src/main.py
+++ b/src/main.py
@@ -112,4 +112,10 @@ async def shutdown_event():
     app_instance = create_application()
     host = os.getenv("HOST", "0.0.0.0")
     port = int(os.getenv("PORT", "8000"))
+
+    if os.getenv("POSTGRES_USER") != "readonly_user":
+        raise ValueError("POSTGRES_USER must be set to readonly_user")
+    if os.getenv("MINIO_ACCESS_KEY") != "minio-readonly":
+        raise ValueError("MINIO_ACCESS_KEY must be set to minio-readonly")
+
     uvicorn.run(app_instance, host=host, port=port)
diff --git a/src/service/error_mapping.py b/src/service/error_mapping.py
index bbceda3..7d5fe40 100644
--- a/src/service/error_mapping.py
+++ b/src/service/error_mapping.py
@@ -24,6 +24,7 @@
     MissingTokenError,
     S3AccessError,
     SparkOperationError,
+    SparkQueryError,
     SparkSessionError,
 )
 
@@ -63,6 +64,7 @@ class ErrorMapping(NamedTuple):
     DeltaLakeError: ErrorMapping(ErrorType.DELTA_LAKE_ERROR, _H400),
     SparkSessionError: ErrorMapping(ErrorType.SPARK_SESSION_ERROR, _H503),
     SparkOperationError: ErrorMapping(ErrorType.SPARK_OPERATION_ERROR, _H503),
+    SparkQueryError: ErrorMapping(ErrorType.SPARK_QUERY_ERROR, _H400),
     # Base error fallback
     MCPServerError: ErrorMapping(None, status.HTTP_500_INTERNAL_SERVER_ERROR),
 }
diff --git a/src/service/errors.py b/src/service/errors.py
index 55f87ff..a16792c 100644
--- a/src/service/errors.py
+++ b/src/service/errors.py
@@ -57,6 +57,9 @@ class ErrorType(Enum):
     SPARK_OPERATION_ERROR = (20080, "Spark operation error")
     """ There was an error executing a Spark operation. """
 
+    SPARK_QUERY_ERROR = (20090, "Spark query error")
+    """ There was an error executing a Spark query. """
+
     REQUEST_VALIDATION_FAILED = (30010, "Request validation failed")
     """ A request to a service failed validation of the request. """
 
diff --git a/src/service/exceptions.py b/src/service/exceptions.py
index 34fcbea..de351d1 100644
--- a/src/service/exceptions.py
+++ b/src/service/exceptions.py
@@ -21,6 +21,12 @@ class SparkOperationError(SparkSessionError):
     """
 
 
+class SparkQueryError(SparkOperationError):
+    """
+    An error thrown when a Spark query fails.
+    """
+
+
 class AuthenticationError(MCPServerError):
     """
     Super class for authentication related errors.
diff --git a/tests/delta_lake/test_delta_service.py b/tests/delta_lake/test_delta_service.py
index 71b434f..d7c9150 100644
--- a/tests/delta_lake/test_delta_service.py
+++ b/tests/delta_lake/test_delta_service.py
@@ -1,6 +1,9 @@
 """Tests for the delta_service module."""
 
+import pytest
+
 from src.delta_lake import delta_service
+from src.service.exceptions import SparkQueryError
 
 
 def test_delta_service_imports():
@@ -8,6 +11,107 @@ def test_delta_service_imports():
     assert delta_service is not None
 
 
-def test_noop():
-    """Simple placeholder test."""
-    assert 1 == 1
+# Lists of valid and invalid queries to test validation logic
+VALID_QUERIES = [
+    "SELECT * FROM my_table",
+    "SELECT id, name FROM users",
+    "SELECT COUNT(*) FROM transactions",
+    "SELECT AVG(amount) FROM payments",
+    "SELECT * FROM table WHERE id > 100",
+    "SELECT DISTINCT category FROM products",
+    "SELECT * FROM orders ORDER BY id DESC",
+    "SELECT * FROM customers LIMIT 10",
+    "SELECT * FROM (SELECT id FROM inner_table) AS subquery",
+    "SELECT t1.id, t2.name FROM table1 t1 JOIN table2 t2 ON t1.id = t2.id",
+    "SELECT * FROM my_table WHERE date BETWEEN '2023-01-01' AND '2023-12-31'",
+    "SELECT id FROM events WHERE type IN ('click', 'view', 'purchase')",
+    "SELECT * FROM sales WHERE region = 'North' AND amount > 1000",
+    "SELECT COALESCE(email, phone, 'no contact') FROM contacts",
+    "SELECT * FROM employees WHERE department LIKE 'eng%'",
+]
+
+# Invalid queries grouped by expected error message
+INVALID_QUERIES = {
+    "must contain exactly one statement": [
+        # Multiple statements
+        "SELECT * FROM users; SELECT * FROM roles",
+        "SELECT * FROM table1; DROP TABLE table2",
+        "SELECT * FROM users; WAITFOR DELAY '0:0:10'--",
+        "SELECT * FROM users WHERE id = ABS(1); DROP TABLE logs; --",
+        "SELECT * FROM users; DROP TABLE logs",
+        # Empty statement
+        "",
+        "   ",
+        "\n\n",
+    ],
+    "must be one of the following: select": [
+        # Non-SELECT statements
+        "INSERT INTO users VALUES (1, 'john')",
+        "UPDATE users SET active = true WHERE id = 1",
+        "DELETE FROM logs WHERE created_at < '2023-01-01'",
+        "DROP TABLE old_data",
+        "CREATE TABLE new_table (id INT, name VARCHAR)",
+        "TRUNCATE TABLE logs",
+        "ALTER TABLE users ADD COLUMN age INT",
+        "MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN UPDATE SET target.val = source.val",
+        "VACUUM delta_table",
+        "CREATE OR REPLACE FUNCTION f() RETURNS void AS $$ SELECT 1; $$ LANGUAGE SQL",
+        "WITH t AS (SELECT 1) DELETE FROM users",
+        # Invalid SQL queries
+        "/**/SEL/**/ECT * FR/**/OM users",
+        "S%45L%45CT%20*%20%46ROM%20users",
+        "S\tE\tL\tE\tC\tT * F\tR\tO\tM users",
+    ],
+    "contains forbidden keyword": [
+        # Forbidden keywords in various contexts
+        # Most of them are not valid SQL queries
+        "Select * from users where id in (DELETE from users where id < 100)",
+        "SELECT * FROM users WHERE DROP = 1",
+        "SELECT ${1+drop}table FROM users",
+        "SELECT * FROM users WHERE id = 1 OR drop = true",
+        "SELECT * FROM users WHERE id = 1 OR drop table users",
+        "SELECT * FROM users WHERE id = 1 OR delete from users",
+        "SELECT * FROM users WHERE id = 1 AND update users set name = 'hacked'",
+        "SELECT * FROM users WHERE id = 1 AND create table hacked (id int)",
+        "SELECT * FROM users WHERE command = 'drop table'",
+        "SELECT * FROM users WHERE col = 'value' OR drop table logs",
+        "SELECT * FROM users WHERE DROP = true",
+    ],
+    "contains disallowed metacharacter": [
+        # Metacharacters
+        "SELECT * FROM users WHERE name = 'user;' ",
+        "/* Comment */ SELECT id FROM users /* Another comment */",
+        "SELECT * FROM users WHERE id = 1 -- bypass filter",
+    ],
+    "contains forbidden PostgreSQL schema": [
+        # PostgreSQL system schemas that should be blocked
+        "SELECT * FROM pg_catalog.pg_tables",
+        "SELECT * FROM information_schema.tables",
+        "SELECT * FROM pg_class",
+        "SELECT tablename FROM pg_tables",
+        "SELECT table_name FROM information_schema.tables",
+        "SELECT * FROM pg_settings WHERE name = 'data_directory'",
+        "SELECT * FROM information_schema.schemata",
+        "SELECT * FROM pg_shadow",
+        "SELECT * FROM pg_authid",
+        "SELECT * FROM pg_stat_activity",
+        "SELECT datname FROM pg_database",
+    ],
+}
+
+
+def test_valid_queries():
+    """Test that all valid queries pass validation."""
+    for query in VALID_QUERIES:
+        try:
+            assert delta_service._check_query_is_valid(query) is True
+        except Exception as e:
+            pytest.fail(f"Valid query '{query}' failed validation: {str(e)}")
+
+
+def test_invalid_queries():
+    """Test that all invalid queries fail validation with the correct error messages."""
+    for error_pattern, queries in INVALID_QUERIES.items():
+        for query in queries:
+            with pytest.raises(SparkQueryError, match=error_pattern):
+                delta_service._check_query_is_valid(query)


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces SQL query validation logic in the delta_service module while adding consistent error handling support via a new SparkQueryError exception.  
- Added new tests to validate SQL queries using sqlparse.  
- Introduced SparkQueryError in exceptions, errors, and error mapping modules.  
- Implemented _check_query_is_valid to validate SQL queries and modified query_delta_table and sample_delta_table to use it.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                | Description                                                                  |
| ----------------------------------- | ---------------------------------------------------------------------------- |
| tests/delta_lake/test_delta_service.py | Added tests with lists of valid and invalid SQL queries to verify behavior. |
| src/service/exceptions.py           | Introduced a new SparkQueryError exception for query validation errors.     |
| src/service/errors.py               | Added corresponding error code for SparkQueryError.                         |
| src/service/error_mapping.py        | Mapped SparkQueryError to an appropriate error code and HTTP status.          |
| src/delta_lake/delta_service.py     | Implemented _check_query_is_valid and updated query validation logic.         |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/delta_lake/delta_service.py:82**
* The variable FORBIDDEN_KEYWORDS is used but not defined; ensure that it is declared or imported to enforce the intended keyword restrictions.
```
if any(keyword in query.lower() for keyword in FORBIDDEN_KEYWORDS):
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T16:42:16Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T17:52:53Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T21:09:52Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T21:11:51Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T21:14:08Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T21:14:18Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T21:15:16Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:21:13Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:22:25Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:38:16Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:38:47Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:38:58Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:39:10Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:40:18Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:41:16Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T21:42:55Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:44:07Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T21:47:53Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T14:21:42Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T17:34:57Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T16:12:28Z
user: login: MrCreosote
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T16:14:09Z
user: login: Tianhao-Gu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T20:29:46Z
user: login: MrCreosote
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T21:00:46Z

Review comments:
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError("Query {query} must contain exactly one statement")
path: src/delta_lake/delta_service.py
user: login: Copilot
body: The error message is missing proper formatting. It should be an f-string (e.g. f"Query {query} must contain exactly one statement") so that the actual query value is displayed.
```suggestion
        raise SparkQueryError(f"Query {query} must contain exactly one statement")
```
created_at: 2025-05-09T16:42:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
+
+    if any(schema in query.lower() for schema in FORBIDDEN_POSTGRESQL_SCHEMAS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden PostgreSQL schema: {', '.join(FORBIDDEN_POSTGRESQL_SCHEMAS)}"
+        )
+
+    if any(char in query for char in DISALLOW_SQL_META_CHARS):
+        raise SparkQueryError(
+            f"Query {query} contains disallowed metacharacter: {', '.join(char for char in DISALLOW_SQL_META_CHARS if char in query)}"
+        )
+
+    if any(keyword in query.lower() for keyword in FORBIDDEN_KEYWORDS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden keyword: {', '.join(keyword for keyword in FORBIDDEN_KEYWORDS if keyword in query.lower())}"
+        )
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: These can cause false positives; for example a column called `time_created` would trigger this error.

These are all statements, right? I would hope that if the code can check all the statements in the query and subqueries from the parsed sql then this would not longer be needed
created_at: 2025-05-09T17:24:43Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: There could be subqueries / nested queries as well, right? can you traverse through them with this lib?
created_at: 2025-05-09T17:25:43Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
+
+    if any(schema in query.lower() for schema in FORBIDDEN_POSTGRESQL_SCHEMAS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden PostgreSQL schema: {', '.join(FORBIDDEN_POSTGRESQL_SCHEMAS)}"
+        )
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: These could also cause false positives but seem much less likely to do so than the forbidden keywords (`jpg_file` is one possibility). Is there no way to get the schema from the parsed sql?

If you can get the table name(s) from the parsed sql checking those directly as prefixes seems like it'd work
created_at: 2025-05-09T17:26:53Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Why is double backslash disallowed?
created_at: 2025-05-09T17:29:47Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: I assume if the sql is invalid this throws an error? Do you need to catch and rethrow with a SparkQueryError?
created_at: 2025-05-09T17:36:15Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Are there any deltalake schemas or tables that should be forbidden?
created_at: 2025-05-09T17:49:57Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: I'm assuming here there's 0 chance we'll ever want to run this on MySQL or some other db engine that has different tables that need to be disallowed
created_at: 2025-05-09T17:51:27Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
+
+    if any(schema in query.lower() for schema in FORBIDDEN_POSTGRESQL_SCHEMAS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden PostgreSQL schema: {', '.join(FORBIDDEN_POSTGRESQL_SCHEMAS)}"
+        )
+
+    if any(char in query for char in DISALLOW_SQL_META_CHARS):
+        raise SparkQueryError(
+            f"Query {query} contains disallowed metacharacter: {', '.join(char for char in DISALLOW_SQL_META_CHARS if char in query)}"
+        )
+
+    if any(keyword in query.lower() for keyword in FORBIDDEN_KEYWORDS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden keyword: {', '.join(keyword for keyword in FORBIDDEN_KEYWORDS if keyword in query.lower())}"
+        )
path: src/delta_lake/delta_service.py
user: login: Tianhao-Gu
body: yea. I realized this as well. Maybe that's okay for now. But I added a note the in code. 
created_at: 2025-05-09T21:09:52Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
path: src/delta_lake/delta_service.py
user: login: Tianhao-Gu
body: yea. I wasn't able to find an easy way to handle subquery within statement. I added a note regarding to that. 
created_at: 2025-05-09T21:11:50Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
path: src/delta_lake/delta_service.py
user: login: Tianhao-Gu
body: it was suggested in the Ticket:https://kbase-jira.atlassian.net/browse/CDM-120

I actually think only ";" matters here but also is checked by `if len(statements) != 1:`.
created_at: 2025-05-09T21:14:08Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
path: src/delta_lake/delta_service.py
user: login: Tianhao-Gu
body: 👍 
created_at: 2025-05-09T21:14:18Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
path: src/delta_lake/delta_service.py
user: login: Tianhao-Gu
body: not anything planned. But it could change - we will need to adjust this setting. 
created_at: 2025-05-09T21:15:16Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Actually, looking at the docs, this parser doesn't validate the sql structure so I assume it won't throw an error. That seems bad: https://github.com/andialbrecht/sqlparse/blob/master/README.rst

This seems better: https://github.com/tobymao/sqlglot

But trying to figure out how to get it to do something simple like find all the sql statements in the parse tree seems like a PITA
created_at: 2025-05-09T21:21:13Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: I asked in the ticket. There are some legit use cases: https://medium.com/@ichsanputr/be-careful-with-backslash-in-mysql-database-0532b9de6a36

That said, easy enough to remove later
created_at: 2025-05-09T21:22:25Z
author_association: MEMBER
diff_hunk: @@ -33,17 +33,22 @@ For detailed documentation, please refer to the [User Guide](docs/guide/user_gui
    mkdir -p cdr/cdm/jupyter/cdm_shared_workspace
    ```
 
-3. Create Docker network:
+3. Make scripts executable:
+   ```bash
+   chmod +x scripts/*.sh
path: README.md
user: login: MrCreosote
body: If you make the scripts executable locally and check them in they should be executable on clones. Works in my hands anyway
created_at: 2025-05-09T21:31:42Z
author_association: MEMBER
diff_hunk: @@ -33,6 +37,68 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    # NOTE: This might create false positives, legitemate queries might include these schemas
+    # e.g. "SELECT * FROM jpg_files"
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+
+    Please note that this function is not a comprehensive SQL query validator.
+    It only checks for basic syntax and structure.
+    MCP server should be configured to use read-only user for both PostgreSQL and MinIO.
+    """
+
+    try:
+        statements = sqlparse.parse(query)
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: ```suggestion
        # NOTE: sqlparse does not validate SQL syntax; what happens with unexpected syntax is unknown
        statements = sqlparse.parse(query)
```
created_at: 2025-05-09T21:35:45Z
author_association: MEMBER
diff_hunk: @@ -112,4 +112,10 @@ async def shutdown_event():
     app_instance = create_application()
     host = os.getenv("HOST", "0.0.0.0")
     port = int(os.getenv("PORT", "8000"))
+
+    if os.getenv("POSTGRES_USER") != "readonly_user":
+        raise ValueError("POSTGRES_USER must be set to readonly_user")
+    if os.getenv("MINIO_ACCESS_KEY") != "minio-readonly":
+        raise ValueError("MINIO_ACCESS_KEY must be set to minio-readonly")
path: src/main.py
user: login: MrCreosote
body: This is effectively hardcoding the user names. Not much point checking the env if they can only be one value
created_at: 2025-05-09T21:37:58Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
+
+    if any(schema in query.lower() for schema in FORBIDDEN_POSTGRESQL_SCHEMAS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden PostgreSQL schema: {', '.join(FORBIDDEN_POSTGRESQL_SCHEMAS)}"
+        )
+
+    if any(char in query for char in DISALLOW_SQL_META_CHARS):
+        raise SparkQueryError(
+            f"Query {query} contains disallowed metacharacter: {', '.join(char for char in DISALLOW_SQL_META_CHARS if char in query)}"
+        )
+
+    if any(keyword in query.lower() for keyword in FORBIDDEN_KEYWORDS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden keyword: {', '.join(keyword for keyword in FORBIDDEN_KEYWORDS if keyword in query.lower())}"
+        )
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Good enough for now
created_at: 2025-05-09T21:38:47Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Also good enough for now
created_at: 2025-05-09T21:38:58Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
+    if len(statements) != 1:
+        raise SparkQueryError(f"Query {query} must contain exactly one statement")
+
+    statement = statements[0]
+    if statement.get_type().lower() not in ALLOWED_STATEMENTS:
+        raise SparkQueryError(
+            f"Query {query} must be one of the following: {', '.join(ALLOWED_STATEMENTS)}, got {statement.get_type()}"
+        )
+
+    if any(schema in query.lower() for schema in FORBIDDEN_POSTGRESQL_SCHEMAS):
+        raise SparkQueryError(
+            f"Query {query} contains forbidden PostgreSQL schema: {', '.join(FORBIDDEN_POSTGRESQL_SCHEMAS)}"
+        )
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Added note, good enough for now
created_at: 2025-05-09T21:39:10Z
author_association: MEMBER
diff_hunk: @@ -33,6 +37,68 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    # NOTE: This might create false positives, legitemate queries might include these schemas
+    # e.g. "SELECT * FROM jpg_files"
+    "pg_",
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: ```suggestion
FORBIDDEN_POSTGRESQL_SCHEMAS = {
    # NOTE: This might create false positives, legitemate queries might include these schemas
    # e.g. "SELECT * FROM jpg_files"
    # NOTE: may also need to expand this if other databases are used
    "pg_",
```
created_at: 2025-05-09T21:40:14Z
author_association: MEMBER
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
+
+
+def _check_query_is_valid(query: str) -> bool:
+    """
+    Check if a query is valid.
+    """
+
+    statements = sqlparse.parse(query)
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Added a note, good enough for now
created_at: 2025-05-09T21:41:16Z
author_association: MEMBER
diff_hunk: @@ -112,4 +112,10 @@ async def shutdown_event():
     app_instance = create_application()
     host = os.getenv("HOST", "0.0.0.0")
     port = int(os.getenv("PORT", "8000"))
+
+    if os.getenv("POSTGRES_USER") != "readonly_user":
+        raise ValueError("POSTGRES_USER must be set to readonly_user")
+    if os.getenv("MINIO_ACCESS_KEY") != "minio-readonly":
+        raise ValueError("MINIO_ACCESS_KEY must be set to minio-readonly")
path: src/main.py
user: login: Tianhao-Gu
body: That's true. My intention is to handle cases where the service might be misconfigured, rather than hardcoding the environment variable here.
created_at: 2025-05-09T21:42:55Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +35,57 @@
     "vacuum",
 }
 
+DISALLOW_SQL_META_CHARS = {
+    "--",
+    "/*",
+    "*/",
+    ";",
+    "\\",
+}
+
+ALLOWED_STATEMENTS = {
+    "select",
+}
+
+FORBIDDEN_POSTGRESQL_SCHEMAS = {
+    "pg_",
+    "pg_catalog",
+    "information_schema",
+}
path: src/delta_lake/delta_service.py
user: login: MrCreosote
body: Added a note, good enough for now
created_at: 2025-05-09T21:44:07Z
author_association: MEMBER
diff_hunk: @@ -112,4 +112,10 @@ async def shutdown_event():
     app_instance = create_application()
     host = os.getenv("HOST", "0.0.0.0")
     port = int(os.getenv("PORT", "8000"))
+
+    if os.getenv("POSTGRES_USER") != "readonly_user":
+        raise ValueError("POSTGRES_USER must be set to readonly_user")
+    if os.getenv("MINIO_ACCESS_KEY") != "minio-readonly":
+        raise ValueError("MINIO_ACCESS_KEY must be set to minio-readonly")
path: src/main.py
user: login: MrCreosote
body: yeah, figured. The only way I can think of doing that without hardcoding the user name (and even that doesn't stop a misconfig if someone is really stupid) is querying postgres and minio to see what privs you have. I don't know how hard that is for postgres, but for minio it's really annoying - you have to list all the buckets and try to write to each one. Aaaand that doesn't stop some one updating the privs to write once the check is over.

If you don't want to hardcode the names I think you're just going to have to trust the person setting up the service. This is where I think rigorous sql parsing (later) could help, so you get two layers of protection - if one fails, hopefully the other will save you
created_at: 2025-05-09T21:47:53Z
author_association: MEMBER
diff_hunk: @@ -112,4 +112,10 @@ async def shutdown_event():
     app_instance = create_application()
     host = os.getenv("HOST", "0.0.0.0")
     port = int(os.getenv("PORT", "8000"))
+
+    if os.getenv("POSTGRES_USER") != "readonly_user":
+        raise ValueError("POSTGRES_USER must be set to readonly_user")
+    if os.getenv("MINIO_ACCESS_KEY") != "minio-readonly":
+        raise ValueError("MINIO_ACCESS_KEY must be set to minio-readonly")
path: src/main.py
user: login: Tianhao-Gu
body: I think it should probably be good enough for now. Compared to other ongoing AI-related projects, this one offers a stronger protection.
created_at: 2025-05-12T14:21:42Z
author_association: COLLABORATOR
diff_hunk: @@ -112,4 +112,10 @@ async def shutdown_event():
     app_instance = create_application()
     host = os.getenv("HOST", "0.0.0.0")
     port = int(os.getenv("PORT", "8000"))
+
+    if os.getenv("POSTGRES_USER") != "readonly_user":
+        raise ValueError("POSTGRES_USER must be set to readonly_user")
+    if os.getenv("MINIO_ACCESS_KEY") != "minio-readonly":
+        raise ValueError("MINIO_ACCESS_KEY must be set to minio-readonly")
path: src/main.py
user: login: MrCreosote
body: fair enough
created_at: 2025-05-12T17:34:56Z
author_association: MEMBER
diff_hunk: @@ -33,17 +33,22 @@ For detailed documentation, please refer to the [User Guide](docs/guide/user_gui
    mkdir -p cdr/cdm/jupyter/cdm_shared_workspace
    ```
 
-3. Create Docker network:
+3. Make scripts executable:
+   ```bash
+   chmod +x scripts/*.sh
path: README.md
user: login: Tianhao-Gu
body: 👍 
created_at: 2025-05-13T16:12:28Z
author_association: COLLABORATOR
diff_hunk: @@ -33,17 +33,22 @@ For detailed documentation, please refer to the [User Guide](docs/guide/user_gui
    mkdir -p cdr/cdm/jupyter/cdm_shared_workspace
    ```
 
-3. Create Docker network:
+3. Make scripts executable:
+   ```bash
+   chmod +x scripts/*.sh
path: README.md
user: login: MrCreosote
body: Looks like the script is executable now so the instructions are no longer necessary
created_at: 2025-05-13T16:14:09Z
author_association: MEMBER
diff_hunk: @@ -33,17 +33,22 @@ For detailed documentation, please refer to the [User Guide](docs/guide/user_gui
    mkdir -p cdr/cdm/jupyter/cdm_shared_workspace
    ```
 
-3. Create Docker network:
+3. Make scripts executable:
+   ```bash
+   chmod +x scripts/*.sh
path: README.md
user: login: Tianhao-Gu
body: 👍 
created_at: 2025-05-13T20:29:46Z
author_association: COLLABORATOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-09T16:36:24Z
body: ## [Codecov](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase) Report
Attention: Patch coverage is `70.96774%` with `9 lines` in your changes missing coverage. Please review.
> Project coverage is 52.45%. Comparing base [(`9c061b8`)](https://app.codecov.io/gh/kbase/cdm-mcp-server/commit/9c061b82f5146706197dc2f3a2f819bc9971a366?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase) to head [(`ce60fe2`)](https://app.codecov.io/gh/kbase/cdm-mcp-server/commit/ce60fe2586107a3706d4643f48b1e4867a2c9d6d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase).
> Report is 18 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase) | Patch % | Lines |
|---|---|---|
| [src/delta\_lake/delta\_service.py](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?src=pr&el=tree&filepath=src%2Fdelta_lake%2Fdelta_service.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase#diff-c3JjL2RlbHRhX2xha2UvZGVsdGFfc2VydmljZS5weQ==) | 79.16% | [5 Missing :warning: ](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase) |
| [src/main.py](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?src=pr&el=tree&filepath=src%2Fmain.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase#diff-c3JjL21haW4ucHk=) | 0.00% | [4 Missing :warning: ](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #15      +/-   ##
==========================================
+ Coverage   51.08%   52.45%   +1.37%     
==========================================
  Files          20       20              
  Lines         648      673      +25     
==========================================
+ Hits          331      353      +22     
- Misses        317      320       +3     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kbase/cdm-mcp-server/pull/15?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kbase).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: Tianhao-Gu
created_at: 2025-05-09T21:21:31Z
body: @MrCreosote I've updated the strategy to use a read-only user for both Postgres and MinIO in this service. With this setup, query validation becomes less critical. I didn't completely remove the `_check_query_is_valid` func as it might still be a good practice to validate queries - but given the read-only constraint, we could remove it completely. 
author_association: COLLABORATOR