This is an automated email from the ASF dual-hosted git repository.

nsivabalan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 71069b2f5259 feat(gateway): add Spark Connect SQL backend (#20329)
71069b2f5259 is described below

commit 71069b2f52591cf275948b23e0cb0793779f5542
Author: Shuo Cheng <[email protected]>
AuthorDate: Mon Nov 2 10:16:41 2026 +0800

    feat(gateway): add Spark Connect SQL backend (#20329)
    
     hudi-agent-gateway can now run its SQL tools against the Hudi Spark
      Connect endpoint instead of Trino. Set GATEWAY_SQL_BACKEND=spark-connect
      or Helm sqlBackend (values-spark-connect.yaml); Trino stays the default.
      list_tables, describe_table and query_lakehouse keep their interfaces
      for both Agent and MCP clients.
    
      - Uses the PySpark Connect client, installed through the optional
        [spark-connect] extra. Each request gets its own session, with up to 8
        concurrent queries, bounded results, timeouts, and interruptAll on
        timeout or cancel.
      - Spark-dialect guardrails keep the gateway read-only. They reject
        script transforms, reflection, file-path and data-source tables,
        IDENTIFIER(), qualified or unknown table functions, and Hudi table
        functions given a path. Two-part names resolve through the catalog.
      - Stopping or disconnecting a streaming chat turn cancels that turn's
        tool tasks, which interrupts the running Spark query.
      - The readiness check uses its own probe and does not compete with
        queries. /v1/info reports the active backend.
      - Chat requests have a deadline. On timeout, clients are told to start a
        new session, because the old one may hold an incomplete tool call.
    
      Part of #20232.
---
 .github/workflows/agent_gateway_ci.yml             |  18 +-
 hudi-agent-gateway/Dockerfile                      |   2 +-
 hudi-agent-gateway/README.md                       |  98 +++-
 hudi-agent-gateway/pyproject.toml                  |   8 +-
 hudi-agent-gateway/src/hudi_agent_gateway/agent.py |  10 +-
 .../src/hudi_agent_gateway/api/chat.py             |  85 +++-
 .../src/hudi_agent_gateway/api/meta.py             |  31 +-
 .../src/hudi_agent_gateway/api/models.py           |  12 +-
 hudi-agent-gateway/src/hudi_agent_gateway/app.py   |  10 +-
 .../{tools/__init__.py => cancellation.py}         |  36 +-
 .../src/hudi_agent_gateway/config.py               |  67 ++-
 .../src/hudi_agent_gateway/mcp_server.py           |   4 +-
 .../src/hudi_agent_gateway/tools/__init__.py       |  18 +-
 .../src/hudi_agent_gateway/tools/guardrails.py     | 138 +++++-
 .../{mcp_server.py => tools/lakehouse_client.py}   |  42 +-
 .../src/hudi_agent_gateway/tools/registry.py       |  11 +
 .../tools/spark_connect_client.py                  | 213 +++++++++
 .../src/hudi_agent_gateway/tools/trino_client.py   |  34 +-
 .../src/hudi_agent_gateway/tools/trino_tools.py    |  91 ++--
 .../src/hudi_agent_gateway/ui/app.js               |  79 +++-
 .../src/hudi_agent_gateway/ui/index.html           |   9 +-
 .../src/hudi_agent_gateway/ui/style.css            |   4 +
 hudi-agent-gateway/tests/conftest.py               |  10 +-
 .../tests/integration/test_spark_connect_live.py   |  84 ++++
 hudi-agent-gateway/tests/test_agent_loop.py        |  14 +
 hudi-agent-gateway/tests/test_api_meta.py          |   4 +-
 hudi-agent-gateway/tests/test_chat_sse.py          | 143 +++++-
 hudi-agent-gateway/tests/test_guardrails.py        | 214 +++++++++
 hudi-agent-gateway/tests/test_mcp.py               |   2 +-
 hudi-agent-gateway/tests/test_spark_connect.py     | 498 +++++++++++++++++++++
 hudi-lakehouse/README.md                           |   9 +-
 .../charts/hudi-agent-gateway/templates/NOTES.txt  |   7 +-
 .../hudi-agent-gateway/templates/deployment.yaml   |  24 +-
 .../hudi-agent-gateway/values-local-dev.yaml       |   1 -
 .../hudi-agent-gateway/values-spark-connect.yaml   |  27 +-
 .../charts/hudi-agent-gateway/values.yaml          |  13 +
 hudi-lakehouse/charts/hudi-spark-connect/README.md |  28 +-
 .../hudi-spark-connect/templates/configmap.yaml    |   1 +
 hudi-lakehouse/tests/test_gateway_chart.py         |  63 +++
 hudi-lakehouse/tests/test_spark_connect_chart.py   |   1 +
 40 files changed, 1952 insertions(+), 211 deletions(-)

diff --git a/.github/workflows/agent_gateway_ci.yml 
b/.github/workflows/agent_gateway_ci.yml
index 768c485b4922..6e16575e3514 100644
--- a/.github/workflows/agent_gateway_ci.yml
+++ b/.github/workflows/agent_gateway_ci.yml
@@ -61,7 +61,7 @@ jobs:
           cache: pip
           cache-dependency-path: hudi-agent-gateway/pyproject.toml
       - name: Install
-        run: pip install -e ".[dev]"
+        run: pip install -e ".[dev,spark-connect]"
       - name: Fetch pinned Hudi Architect baseline
         run: >-
           git fetch --no-tags --depth=1 origin
@@ -98,6 +98,18 @@ jobs:
           
../hudi-ai-operator/skills/hudi-architect/validate_flink_capabilities.py
           ../hudi-ai-operator/skills/hudi-architect/validate_flink_design.py
           
../hudi-ai-operator/skills/hudi-architect/validate_flink_existing_table.py
+      - name: Test guardrails with minimum sqlglot
+        # Read the lower bound from project metadata so this test follows 
future bumps.
+        run: |
+          sqlglot_min_requirement=$(python - <<'PY'
+          import tomllib
+          with open("pyproject.toml", "rb") as f:
+              dependencies = tomllib.load(f)["project"]["dependencies"]
+          print(next(dep for dep in dependencies if 
dep.startswith("sqlglot>=")).replace(">=", "==", 1))
+          PY
+          )
+          python -m pip install "$sqlglot_min_requirement"
+          python -m pytest tests/test_guardrails.py -q
 
   flink-sql-fixtures:
     name: Hudi 1.2.0 / Flink 1.20.1 SQL fixtures
@@ -145,3 +157,7 @@ jobs:
               helm template "$chart" > /dev/null
             fi
           done
+          helm lint hudi-lakehouse/charts/hudi-agent-gateway \
+            -f 
hudi-lakehouse/charts/hudi-agent-gateway/values-spark-connect.yaml
+          helm template hudi-lakehouse/charts/hudi-agent-gateway \
+            -f 
hudi-lakehouse/charts/hudi-agent-gateway/values-spark-connect.yaml > /dev/null
diff --git a/hudi-agent-gateway/Dockerfile b/hudi-agent-gateway/Dockerfile
index 27fae43b0ce6..91a4b92243f2 100644
--- a/hudi-agent-gateway/Dockerfile
+++ b/hudi-agent-gateway/Dockerfile
@@ -27,7 +27,7 @@ WORKDIR /opt/hudi-agent-gateway
 COPY pyproject.toml README.md ./
 RUN mkdir -p src/hudi_agent_gateway \
     && printf '__version__ = "0.1.0"\n' > src/hudi_agent_gateway/__init__.py \
-    && pip install --no-cache-dir . \
+    && pip install --no-cache-dir '.[spark-connect]' \
     && pip uninstall -y hudi-agent-gateway
 COPY src ./src
 RUN pip install --no-cache-dir --no-deps .
diff --git a/hudi-agent-gateway/README.md b/hudi-agent-gateway/README.md
index d1ca1c2435c6..eecdb2ed95c1 100644
--- a/hudi-agent-gateway/README.md
+++ b/hudi-agent-gateway/README.md
@@ -26,17 +26,26 @@ process hosts three surfaces over the same set of guarded 
lakehouse tools:
 | MCP server | `/mcp` (streamable HTTP) | external agents (Claude, anything 
MCP) call the lakehouse tools directly |
 | Chat UI | `/ui/` | first-party ChatGPT-style web UI (zero third-party code) |
 
-The v1 tools query the lakehouse through Trino: `query_lakehouse` (guarded,
+The tools query the lakehouse through Trino (default) or the official PySpark
+Connect client: `query_lakehouse` (guarded,
 read-only SQL), `list_tables`, `describe_table`. Every model-written query
 passes AST-level guardrails (single statement, SELECT-only, row cap injected
 as a real `LIMIT`) and every invocation is logged as structured JSON — the
 seed of the gateway's trace collection.
 
+For Spark, dynamic `IDENTIFIER(...)` calls and custom or qualified table 
functions
+are rejected. Allowed table functions are `range`, `explode`, `explode_outer`,
+`posexplode`, `posexplode_outer`, `inline`, `inline_outer`, `stack`, 
`json_tuple`,
+and `hudi_query`, `hudi_table_changes`, `hudi_query_timeline`, 
`hudi_filesystem_view`,
+`hudi_metadata`. Hudi functions require a literal catalog table name such as
+`'default.orders'`; paths and computed table names are rejected.
+`hudi_filesystem_view` accepts only the table argument, without a subpath.
+
 ## Quickstart (local process)
 
 ```bash
 cd hudi-agent-gateway
-python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"
+python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev,spark-connect]"
 
 # point at a Trino with the Hudi connector (e.g. the local-dev stack, 
port-forwarded):
 GATEWAY_TRINO_HOST=localhost GATEWAY_TRINO_PORT=18080 \
@@ -61,6 +70,14 @@ ollama pull qwen3:8b
 GATEWAY_LLM_PROVIDER=ollama GATEWAY_LLM_MODEL=qwen3:8b hudi-agent-gateway serve
 ```
 
+The chat UI shows progress and elapsed time while a response is running. 
**Stop**
+closes the response stream and cancels the agent task. If the gateway does not
+accept the connection within 15 seconds, the UI reports a connection timeout.
+The server's chat timeout returns HTTP 504
+for JSON requests or a `chat_timeout` SSE error. Provider/SQL cancellation is
+best-effort; remote work may take time to stop. After Stop or an error the UI
+starts a new conversation on the next message to avoid incomplete tool-call 
history.
+
 Connect an MCP client:
 
 ```bash
@@ -85,7 +102,11 @@ Environment variables (prefix `GATEWAY_` except the 
standard key names):
 | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | — | required by the matching 
provider |
 | `GATEWAY_OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama endpoint |
 | `GATEWAY_OPENAI_BASE_URL` | — | endpoint for `openai-compatible` (vLLM, 
Together, …) |
-| `GATEWAY_LLM_TIMEOUT_SECONDS` | `120` | per-model-call timeout |
+| `GATEWAY_LLM_TIMEOUT_SECONDS` | `120` | HTTP operation/read timeout; 
streaming data can keep a call alive |
+| `GATEWAY_CHAT_TIMEOUT_SECONDS` | `300` | total wall-clock limit for one chat 
turn, including all model/tool calls |
+| `GATEWAY_SQL_BACKEND` | `trino` | `trino` or `spark-connect` |
+| `GATEWAY_SPARK_CONNECT_URI` | 
`sc://hudi-spark-connect.hudi-lakehouse.svc:15002` | PySpark Connect URI; 
optional `/;key=value` parameters |
+| `GATEWAY_SPARK_CONNECT_CATALOG` / `_SCHEMA` | `spark_catalog` / `default` | 
Spark query and metadata defaults |
 | `GATEWAY_TRINO_HOST` / `_PORT` | `hudi-trino.hudi-lakehouse.svc` / `8080` | 
Trino coordinator |
 | `GATEWAY_TRINO_CATALOG` / `_SCHEMA` / `_USER` | `hudi` / `default` / 
`hudi-agent-gateway` | query defaults |
 | `GATEWAY_SQL_ROW_CAP` | `200` | LIMIT enforced on every query |
@@ -98,10 +119,79 @@ Environment variables (prefix `GATEWAY_` except the 
standard key names):
 | `GATEWAY_MCP_ENABLED` | `true` | serve /mcp |
 | `GATEWAY_HOST` / `GATEWAY_PORT` / `GATEWAY_LOG_LEVEL` | `0.0.0.0` / `8000` / 
`INFO` | server basics |
 
-Startup never depends on the LLM or Trino being reachable: `/health` is
+Startup never depends on the LLM or the selected SQL backend being reachable: 
`/health` is
 liveness, `/ready` reports per-dependency status (and gates the Kubernetes
 readiness probe).
 
+## Spark Connect backend
+
+Install the gateway in a Python 3.11/3.12 environment with the `spark-connect`
+extra. It includes `pyspark-client==4.1.3`, matching the chart's Spark version,
+and PyArrow 18.1.0. The gateway image includes this extra.
+The official Python Connect client sends RPCs to the remote Spark server; the
+gateway image requires no Java runtime, Spark JARs, or native ADBC driver.
+PySpark also brings pandas, gRPC, and protobuf dependencies; use a dedicated
+environment and upgrade the client alongside the server.
+
+After deploying the [Spark Connect 
chart](../hudi-lakehouse/charts/hudi-spark-connect/README.md)
+and forwarding port 15002:
+
+```bash
+GATEWAY_SQL_BACKEND=spark-connect \
+GATEWAY_SPARK_CONNECT_URI='sc://localhost:15002' \
+  hudi-agent-gateway serve
+```
+
+Use Helm `sqlBackend: spark-connect` and `sparkConnect.uri/catalog/schema`,
+or apply the chart's `values-spark-connect.yaml` overlay. An optional
+`sparkConnect.existingSecret` supplies the URI via `sparkConnect.uriSecretKey`.
+`/ready` reports a `spark-connect` check (cached SELECT 1) and the LLM check;
+`/v1/info`, Agent prompts, MCP tool descriptions, and the UI reflect the 
selected
+backend. Connection credentials are omitted from `/v1/info`.
+
+`query_lakehouse` accepts Spark SQL with backtick identifiers and enforces the
+same single-SELECT/AST/LIMIT policy. `list_tables` uses SHOW TABLES and reports
+`TABLE` for persistent objects (Spark's SHOW TABLES does not distinguish views
+from tables), `TEMPORARY` for temporary objects. `describe_table` uses a 
zero-row
+SELECT and PySpark's schema, preserving column/type/nullability result columns.
+Types use Spark SQL names such as `int`, `bigint`, and `array<string>`. Each 
call
+creates an independent remote session, sets its catalog/schema, and stops that
+session in a finally block. Sessions are not shared across tool calls.
+
+**Bounds:** SQL LIMIT and DataFrame.limit before collect cap query result rows;
+the existing JSON size cap truncates tool responses. Wide rows can still 
consume
+substantial client memory, and SHOW TABLES can enumerate the whole namespace
+before returning limited rows. When capped, SHOW TABLES can return an arbitrary
+subset, since Spark does not guarantee its order. The JSON cap is a response 
limit, not a
+server/memory budget. Queries use at most eight workers with no accumulating
+queue. A timed-out call keeps its capacity slot until execution and cleanup
+actually finish.
+
+**Timeout/cancellation:** gateway timeouts return promptly and request
+`SparkSession.interruptAll()` on a separate thread; task cancellation follows
+the same path. Stopping a streaming chat also cancels the tool tasks belonging
+to that turn, including those running inside the agent. Each request owns its
+session, so this interrupts only that request's operations. Spark provides a
+server interruption RPC, but network
+failures and races with operation startup mean cancellation remains 
best-effort.
+A timeout does not prove Spark stopped. Hung RPCs can delay session cleanup;
+worker pools shut down without waiting, and Python cannot forcibly terminate
+worker threads. Restart the process if all eight slots remain stuck. Connect
+URIs can use `/;use_ssl=true;token=...` for an appropriately configured 
endpoint;
+the chart itself has no authentication or TLS. Gateway tools expose no DML;
+direct Superset/Zeppelin writes have their own
+[tutorials and 
limitations](../hudi-lakehouse/charts/hudi-spark-connect/README.md#spark-connect-client-tutorials).
+
+Opt-in live tests use an existing table and do not write data:
+
+```bash
+GATEWAY_IT_SPARK_CONNECT_URI='sc://localhost:15002' \
+GATEWAY_IT_SPARK_TABLE=bi_adbc_demo.orders \
+  .venv/bin/python -m pytest tests/integration/test_spark_connect_live.py -q
+```
+
+Add `GATEWAY_IT_LLM=1` to exercise the real Agent loop with the configured LLM.
+
 ## Development
 
 ```bash
diff --git a/hudi-agent-gateway/pyproject.toml 
b/hudi-agent-gateway/pyproject.toml
index 8b548f2e5271..12d71c3f47bb 100644
--- a/hudi-agent-gateway/pyproject.toml
+++ b/hudi-agent-gateway/pyproject.toml
@@ -35,7 +35,8 @@ dependencies = [
   "langchain-ollama>=1.0",
   "fastmcp>=2.10,<3",
   "trino>=0.330",
-  "sqlglot>=25.0",
+  # Guardrails rely on AST shapes verified at this minimum version in CI.
+  "sqlglot>=30.21.0",
   "pydantic>=2.7",
   "pydantic-settings>=2.3",
   "sse-starlette>=2.1",
@@ -43,6 +44,11 @@ dependencies = [
 ]
 
 [project.optional-dependencies]
+spark-connect = [
+  # The session cleanup workaround relies on PySpark's private client API.
+  "pyspark-client==4.1.3",
+  "pyarrow==18.1.0",
+]
 dev = [
   "pytest>=8.0",
   "pytest-asyncio>=0.24",
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/agent.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/agent.py
index eb2add9873ff..2c4217fc3cf0 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/agent.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/agent.py
@@ -31,13 +31,13 @@ from hudi_agent_gateway.tools.registry import ToolRegistry
 
 _SYSTEM_PROMPT = """\
 You are the Apache Hudi lakehouse analyst. You answer questions about data in \
-an Apache Hudi lakehouse by querying it through Trino using your tools. The \
+an Apache Hudi lakehouse by querying it through {backend} using your tools. 
The \
 default catalog is `{catalog}` and the default schema is `{schema}`; tables \
 are Hudi tables registered in the catalog's metastore.
 
 Tool strategy:
 - For unfamiliar tables, call `list_tables` and `describe_table` BEFORE 
writing SQL.
-- `query_lakehouse` accepts exactly ONE read-only SELECT statement (Trino 
SQL). \
+- `query_lakehouse` accepts exactly ONE read-only SELECT statement ({dialect} 
SQL). \
 Qualify names as catalog.schema.table when querying outside the defaults.
 - A server-side row cap of {row_cap} rows is enforced. Prefer aggregations, \
 GROUP BY, and explicit LIMIT over raw row dumps.
@@ -55,8 +55,10 @@ Grounding rules:
 def build_system_prompt(settings: GatewaySettings) -> str:
     extra = f"\n{settings.system_prompt_extra}" if 
settings.system_prompt_extra else ""
     return _SYSTEM_PROMPT.format(
-        catalog=settings.trino_catalog,
-        schema=settings.trino_schema,
+        backend="Spark Connect" if settings.sql_backend == "spark-connect" 
else "Trino",
+        dialect="Spark" if settings.sql_backend == "spark-connect" else 
"Trino",
+        catalog=settings.lakehouse_catalog,
+        schema=settings.lakehouse_schema,
         row_cap=settings.sql_row_cap,
         extra=extra,
     )
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/api/chat.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/api/chat.py
index 08263e256155..c6a7efcc8e5e 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/api/chat.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/api/chat.py
@@ -18,9 +18,11 @@
 
 from __future__ import annotations
 
+import asyncio
 import logging
 import uuid
 from collections.abc import AsyncIterator
+from contextlib import aclosing
 from typing import Any
 
 from fastapi import APIRouter, HTTPException, Request
@@ -33,11 +35,13 @@ from hudi_agent_gateway.api.models import (
     ChatResponse,
     DoneEvent,
     ErrorEvent,
+    StatusEvent,
     TokenEvent,
     ToolCallEvent,
     ToolResultEvent,
     ToolTraceEntry,
 )
+from hudi_agent_gateway.cancellation import ToolCancellationScope, 
tool_cancellation_scope
 from hudi_agent_gateway.log import log_event, request_id_var, session_id_var
 from hudi_agent_gateway.tools.registry import is_tool_error
 
@@ -46,10 +50,10 @@ logger = logging.getLogger("hudi_agent_gateway.chat")
 
 def _message_text(msg: Any) -> str:
     text = getattr(msg, "text", None)
-    if callable(text):  # langchain-core < 1.x compatibility
-        return text()
     if isinstance(text, str):
         return text
+    if callable(text):  # langchain-core < 1.x compatibility
+        return text()
     return str(getattr(msg, "content", ""))
 
 
@@ -97,10 +101,13 @@ async def chat(request: Request, body: ChatRequest) -> Any:
     if wants_stream:
         return EventSourceResponse(_stream_chat(request, body))
 
-    agent = state.agents.get(body.model)
     inputs = {"messages": [HumanMessage(body.message)]}
     try:
-        result = await agent.ainvoke(inputs, config=_agent_config(request, 
body.session_id))
+        async with asyncio.timeout(state.settings.chat_timeout_seconds):
+            agent = state.agents.get(body.model)
+            result = await agent.ainvoke(inputs, config=_agent_config(request, 
body.session_id))
+    except TimeoutError as e:
+        raise HTTPException(status_code=504, detail=_timeout_message(request)) 
from e
     except GraphRecursionError as e:
         raise HTTPException(
             status_code=422,
@@ -126,27 +133,44 @@ async def chat(request: Request, body: ChatRequest) -> 
Any:
 
 async def _stream_chat(request: Request, body: ChatRequest) -> 
AsyncIterator[ServerSentEvent]:
     state = request.app.state
-    agent = state.agents.get(body.model)
     inputs = {"messages": [HumanMessage(body.message)]}
     final_text_parts: list[str] = []
+    yield _status_event("Preparing response…")
+    scope = ToolCancellationScope()
+    scope_token = tool_cancellation_scope.set(scope)
     try:
-        async for mode, chunk in agent.astream(
-            inputs,
-            config=_agent_config(request, body.session_id),
-            stream_mode=["messages", "updates"],
-        ):
-            if mode == "messages":
-                msg_chunk, _meta = chunk
-                if isinstance(msg_chunk, AIMessageChunk):
-                    text = _message_text(msg_chunk)
-                    if text:
-                        final_text_parts.append(text)
-                        yield ServerSentEvent(
-                            event="token", 
data=TokenEvent(text=text).model_dump_json()
-                        )
-            elif mode == "updates" and isinstance(chunk, dict):
-                for events in _updates_to_events(chunk):
-                    yield events
+        async with asyncio.timeout(state.settings.chat_timeout_seconds):
+            agent = state.agents.get(body.model)
+            async with aclosing(agent.astream(
+                inputs,
+                config=_agent_config(request, body.session_id),
+                stream_mode=["messages", "updates"],
+            )) as stream:
+                async for mode, chunk in stream:
+                    if mode == "messages":
+                        msg_chunk, _meta = chunk
+                        if isinstance(msg_chunk, AIMessageChunk):
+                            text = _message_text(msg_chunk)
+                            if text:
+                                final_text_parts.append(text)
+                                yield ServerSentEvent(
+                                    event="token", 
data=TokenEvent(text=text).model_dump_json()
+                                )
+                    elif mode == "updates" and isinstance(chunk, dict):
+                        for event in _updates_to_events(chunk):
+                            yield event
+    except TimeoutError:
+        log_event(logger, "chat_timeout", 
timeout_seconds=state.settings.chat_timeout_seconds)
+        yield ServerSentEvent(
+            event="error",
+            data=ErrorEvent(
+                code="chat_timeout", message=_timeout_message(request)
+            ).model_dump_json(),
+        )
+        return
+    except asyncio.CancelledError:
+        log_event(logger, "chat_cancelled")
+        raise
     except GraphRecursionError:
         yield ServerSentEvent(
             event="error",
@@ -163,6 +187,11 @@ async def _stream_chat(request: Request, body: 
ChatRequest) -> AsyncIterator[Ser
             data=ErrorEvent(code="agent_error", 
message=str(e)).model_dump_json(),
         )
         return
+    finally:
+        # LangGraph may run tools in tasks that outlive its streaming iterator.
+        # Cancel only this turn's tasks when the stream ends or disconnects.
+        scope.cancel()
+        tool_cancellation_scope.reset(scope_token)
 
     yield ServerSentEvent(
         event="done",
@@ -172,8 +201,20 @@ async def _stream_chat(request: Request, body: 
ChatRequest) -> AsyncIterator[Ser
     )
 
 
+def _timeout_message(request: Request) -> str:
+    seconds = request.app.state.settings.chat_timeout_seconds
+    return (f"The response exceeded the {seconds:g}s time limit. Start a new 
session "
+            "before retrying, because this session may contain an incomplete 
tool call.")
+
+
+def _status_event(message: str) -> ServerSentEvent:
+    return ServerSentEvent(event="status", 
data=StatusEvent(message=message).model_dump_json())
+
+
 def _updates_to_events(update: dict[str, Any]) -> list[ServerSentEvent]:
     events: list[ServerSentEvent] = []
+    if "pre_model_hook" in update:
+        events.append(_status_event("Waiting for model…"))
     agent_update = update.get("agent") or {}
     for msg in agent_update.get("messages", []):
         if isinstance(msg, AIMessage):
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/api/meta.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/api/meta.py
index 0273b7798859..8d881aab87d6 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/api/meta.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/api/meta.py
@@ -19,6 +19,7 @@
 from __future__ import annotations
 
 import time
+from urllib.parse import urlparse
 
 from fastapi import APIRouter, Request, Response
 
@@ -46,11 +47,12 @@ async def ready(request: Request, response: Response) -> 
ReadyResponse:
     state = request.app.state
     checks: dict[str, DependencyStatus] = {}
 
-    trino_ok = await state.trino_client.ping()
-    checks["trino"] = DependencyStatus(
-        ok=trino_ok,
-        detail="reachable" if trino_ok else
-        f"cannot reach trino at 
{state.settings.trino_host}:{state.settings.trino_port}",
+    lakehouse_ok = await state.lakehouse_client.ping()
+    backend = state.settings.sql_backend
+    checks[backend] = DependencyStatus(
+        ok=lakehouse_ok,
+        detail="reachable" if lakehouse_ok else
+        f"cannot query {backend}; check connection settings, client driver, 
and server logs",
     )
 
     llm_ok, llm_detail = await state.llm_readiness.check()
@@ -86,14 +88,25 @@ async def models(request: Request) -> ModelsResponse:
 @router.get("/v1/info", response_model=InfoResponse, 
response_model_by_alias=True)
 async def info(request: Request) -> InfoResponse:
     s = request.app.state.settings
+    lakehouse_url = f"http://{s.trino_host}:{s.trino_port}";
+    if s.sql_backend == "spark-connect":
+        uri = urlparse("http" + s.spark_connect_uri[2:])
+        host = uri.hostname or ""
+        if ":" in host:
+            host = f"[{host}]"
+        lakehouse_url = f"sc://{host}:{uri.port or 15002}"
     return InfoResponse(
         name="hudi-agent-gateway",
         version=__version__,
         provider=s.llm_provider,
         model=s.llm_model,
-        catalog=s.trino_catalog,
-        schema=s.trino_schema,
-        trino_url=f"http://{s.trino_host}:{s.trino_port}";,
-        trino_ui_url=f"http://{s.trino_host}:{s.trino_port}/ui/";,
+        catalog=s.lakehouse_catalog,
+        schema=s.lakehouse_schema,
+        sql_backend=s.sql_backend,
+        lakehouse_url=lakehouse_url,
+        trino_url=f"http://{s.trino_host}:{s.trino_port}"; if s.sql_backend == 
"trino" else "",
+        trino_ui_url=(
+            f"http://{s.trino_host}:{s.trino_port}/ui/"; if s.sql_backend == 
"trino" else ""
+        ),
         mcp_enabled=s.mcp_enabled,
     )
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/api/models.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/api/models.py
index 0c574e38829c..b78a65cbfcd4 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/api/models.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/api/models.py
@@ -54,6 +54,10 @@ class ChatResponse(BaseModel):
 # --- SSE event payloads (each is the JSON body of one `data:` line) ---
 
 
+class StatusEvent(BaseModel):
+    message: str
+
+
 class TokenEvent(BaseModel):
     text: str
 
@@ -77,7 +81,7 @@ class DoneEvent(BaseModel):
 
 
 class ErrorEvent(BaseModel):
-    code: Literal["llm_error", "agent_error", "recursion_limit"]
+    code: Literal["llm_error", "agent_error", "recursion_limit", 
"chat_timeout"]
     message: str
 
 
@@ -105,8 +109,10 @@ class InfoResponse(BaseModel):
     catalog: str
     schema_: str = Field(alias="schema")
     # In-cluster/network endpoints, for the UI's connect panel.
-    trino_url: str
-    trino_ui_url: str
+    sql_backend: str = "trino"
+    lakehouse_url: str = ""
+    trino_url: str = ""
+    trino_ui_url: str = ""
     mcp_enabled: bool
 
     model_config = {"populate_by_name": True}
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/app.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/app.py
index de1faf8e8a6e..32e919e8cd11 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/app.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/app.py
@@ -36,8 +36,7 @@ from hudi_agent_gateway.llm import LLMReadiness
 from hudi_agent_gateway.log import log_event, setup_logging
 from hudi_agent_gateway.mcp_server import build_mcp
 from hudi_agent_gateway.sessions import SessionStore
-from hudi_agent_gateway.tools import build_registry
-from hudi_agent_gateway.tools.trino_client import TrinoClient
+from hudi_agent_gateway.tools import build_client, build_registry
 
 logger = logging.getLogger("hudi_agent_gateway.app")
 
@@ -46,8 +45,8 @@ def create_app(settings: GatewaySettings | None = None) -> 
FastAPI:
     s = settings or GatewaySettings()
     setup_logging(s.log_level)
 
-    trino_client = TrinoClient(s)
-    registry = build_registry(s, trino_client)
+    lakehouse_client = build_client(s)
+    registry = build_registry(s, lakehouse_client)
     # NOTE: the MCP ASGI sub-app has its own lifespan that MUST run inside the
     # outer app's lifespan, or FastMCP's streamable-HTTP session manager never
     # starts and /mcp requests hang.
@@ -56,7 +55,7 @@ def create_app(settings: GatewaySettings | None = None) -> 
FastAPI:
     @asynccontextmanager
     async def lifespan(app: FastAPI) -> AsyncIterator[None]:
         app.state.settings = s
-        app.state.trino_client = trino_client
+        app.state.lakehouse_client = lakehouse_client
         app.state.registry = registry
         app.state.llm_readiness = LLMReadiness(s)
         app.state.sessions = SessionStore(s)
@@ -79,6 +78,7 @@ def create_app(settings: GatewaySettings | None = None) -> 
FastAPI:
                 yield
         finally:
             sweeper.cancel()
+            lakehouse_client.close()
 
     app = FastAPI(title="hudi-agent-gateway", version=__version__, 
lifespan=lifespan)
     app.include_router(meta.router)
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/cancellation.py
similarity index 52%
copy from hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
copy to hudi-agent-gateway/src/hudi_agent_gateway/cancellation.py
index 9e4db82cdc7b..30d8b70f5cac 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/cancellation.py
@@ -14,24 +14,28 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""Tool assembly: the single place where tool modules get registered.
+"""Track tool tasks belonging to one streaming chat turn."""
 
-Adding a new tool module = write the module with a ``register(registry, ...)``
-function, then call it here.
-"""
+import asyncio
+from contextvars import ContextVar
 
-from __future__ import annotations
 
-from hudi_agent_gateway.config import GatewaySettings
-from hudi_agent_gateway.tools import trino_tools
-from hudi_agent_gateway.tools.registry import ToolRegistry
-from hudi_agent_gateway.tools.trino_client import TrinoClient
+class ToolCancellationScope:
+    def __init__(self) -> None:
+        self.tasks: set[asyncio.Task] = set()
+        self.cancelled = False
 
+    def add(self, task: asyncio.Task) -> None:
+        if self.cancelled:
+            raise asyncio.CancelledError
+        self.tasks.add(task)
 
-def build_registry(
-    settings: GatewaySettings, trino_client: TrinoClient | None = None
-) -> ToolRegistry:
-    registry = ToolRegistry()
-    client = trino_client or TrinoClient(settings)
-    trino_tools.register(registry, client, settings)
-    return registry
+    def cancel(self) -> None:
+        self.cancelled = True
+        for task in tuple(self.tasks):
+            task.cancel()
+
+
+tool_cancellation_scope: ContextVar[ToolCancellationScope | None] = ContextVar(
+    "tool_cancellation_scope", default=None
+)
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/config.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/config.py
index 46f2d9836a08..34b300ee7d8a 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/config.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/config.py
@@ -25,6 +25,7 @@ through ``/ready`` instead.
 from __future__ import annotations
 
 from typing import Literal
+from urllib.parse import urlparse
 
 from pydantic import AliasChoices, Field, model_validator
 from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -33,9 +34,7 @@ LLMProvider = Literal["anthropic", "openai", "ollama", 
"openai-compatible"]
 
 
 class GatewaySettings(BaseSettings):
-    model_config = SettingsConfigDict(
-        env_prefix="GATEWAY_", extra="ignore", populate_by_name=True
-    )
+    model_config = SettingsConfigDict(env_prefix="GATEWAY_", extra="ignore", 
populate_by_name=True)
 
     # --- server ---
     host: str = "0.0.0.0"
@@ -58,7 +57,13 @@ class GatewaySettings(BaseSettings):
     ollama_base_url: str = "http://localhost:11434";
     openai_base_url: str = ""  # required for provider=openai-compatible (vLLM 
etc.)
 
-    # --- Trino / lakehouse ---
+    # --- lakehouse ---
+    sql_backend: Literal["trino", "spark-connect"] = "trino"
+    spark_connect_uri: str = "sc://hudi-spark-connect.hudi-lakehouse.svc:15002"
+    spark_connect_catalog: str = "spark_catalog"
+    spark_connect_schema: str = "default"
+
+    # --- Trino ---
     trino_host: str = "hudi-trino.hudi-lakehouse.svc"
     trino_port: int = 8080
     trino_user: str = "hudi-agent-gateway"
@@ -66,12 +71,13 @@ class GatewaySettings(BaseSettings):
     trino_schema: str = "default"
 
     # --- guardrails / shaping ---
-    sql_row_cap: int = 200
-    sql_timeout_seconds: float = 120.0
-    tool_result_max_bytes: int = 50_000
+    sql_row_cap: int = Field(default=200, gt=0)
+    sql_timeout_seconds: float = Field(default=120.0, gt=0)
+    tool_result_max_bytes: int = Field(default=50_000, ge=80)
 
     # --- agent / sessions ---
     agent_max_iterations: int = 25
+    chat_timeout_seconds: float = Field(default=300.0, gt=0)
     session_ttl_seconds: float = 3600.0
     max_sessions: int = 1000
     max_messages_per_session: int = 40
@@ -80,6 +86,53 @@ class GatewaySettings(BaseSettings):
     # --- surfaces ---
     mcp_enabled: bool = True
 
+    @property
+    def sql_dialect(self) -> str:
+        return "spark" if self.sql_backend == "spark-connect" else "trino"
+
+    @property
+    def lakehouse_catalog(self) -> str:
+        return (
+            self.spark_connect_catalog
+            if self.sql_backend == "spark-connect"
+            else self.trino_catalog
+        )
+
+    @property
+    def lakehouse_schema(self) -> str:
+        return (
+            self.spark_connect_schema if self.sql_backend == "spark-connect" 
else self.trino_schema
+        )
+
+    @model_validator(mode="after")
+    def _validate_spark_connect(self) -> GatewaySettings:
+        if self.sql_backend == "spark-connect":
+            try:
+                # PySpark parses semicolon parameters with an HTTP URI parser.
+                uri = urlparse("http" + self.spark_connect_uri[2:])
+                port = uri.port
+            except ValueError as e:
+                raise ValueError(
+                    "GATEWAY_SPARK_CONNECT_URI requires a valid URI with a 
port from 1 to 65535"
+                ) from e
+            if port is not None and not 1 <= port <= 65535:
+                raise ValueError("GATEWAY_SPARK_CONNECT_URI port must be from 
1 to 65535")
+            if (
+                not self.spark_connect_uri.startswith("sc://")
+                or not uri.hostname
+                or uri.username is not None
+                or uri.password is not None
+                or uri.path not in ("", "/")
+                or uri.query
+                or uri.fragment
+                or (uri.params and any(len(p.split("=")) != 2 for p in 
uri.params.split(";")))
+            ):
+                raise ValueError(
+                    "GATEWAY_SPARK_CONNECT_URI requires sc://host:port "
+                    "with optional /;key=value parameters"
+                )
+        return self
+
     @model_validator(mode="after")
     def _validate_provider_requirements(self) -> GatewaySettings:
         if self.llm_provider == "anthropic" and not self.anthropic_api_key:
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py
index f1d17fd55365..308e42f02705 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py
@@ -24,9 +24,9 @@ from hudi_agent_gateway import __version__
 from hudi_agent_gateway.tools.registry import ToolRegistry
 
 _INSTRUCTIONS = (
-    "Tools for querying an Apache Hudi lakehouse through Trino. Use 
list_tables "
+    "Tools for querying an Apache Hudi lakehouse. Use list_tables "
     "and describe_table to discover the schema, then query_lakehouse with a "
-    "single read-only SELECT statement (Trino SQL). Results are row-capped and 
"
+    "single read-only SELECT statement in the tool's SQL dialect. Results are 
row-capped and "
     "may be truncated (indicated in the payload)."
 )
 
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
index 9e4db82cdc7b..91ed10c1b4c9 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
@@ -24,14 +24,28 @@ from __future__ import annotations
 
 from hudi_agent_gateway.config import GatewaySettings
 from hudi_agent_gateway.tools import trino_tools
+from hudi_agent_gateway.tools.lakehouse_client import LakehouseClient
 from hudi_agent_gateway.tools.registry import ToolRegistry
 from hudi_agent_gateway.tools.trino_client import TrinoClient
 
 
+def build_client(settings: GatewaySettings) -> LakehouseClient:
+    if settings.sql_backend == "spark-connect":
+        try:
+            from hudi_agent_gateway.tools.spark_connect_client import 
SparkConnectClient
+        except ModuleNotFoundError as e:
+            if e.name and (e.name == "pyspark" or 
e.name.startswith("pyspark.")):
+                raise RuntimeError("Spark Connect requires the spark-connect 
extra") from e
+            raise
+
+        return SparkConnectClient(settings)
+    return TrinoClient(settings)
+
+
 def build_registry(
-    settings: GatewaySettings, trino_client: TrinoClient | None = None
+    settings: GatewaySettings, lakehouse_client: LakehouseClient | None = None
 ) -> ToolRegistry:
     registry = ToolRegistry()
-    client = trino_client or TrinoClient(settings)
+    client = lakehouse_client or build_client(settings)
     trino_tools.register(registry, client, settings)
     return registry
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/guardrails.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/guardrails.py
index ec261eada2ca..8bf048fe035d 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/guardrails.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/guardrails.py
@@ -16,7 +16,7 @@
 
 """SQL guardrails for model-written queries.
 
-AST-level (sqlglot, Trino dialect) rather than regex: read-only enforcement
+AST-level (sqlglot, selected backend dialect) rather than regex: read-only 
enforcement
 survives comments, CTEs, and string literals; the row cap is injected as a
 real ``LIMIT``. Fail-closed: anything that does not parse is rejected.
 """
@@ -30,6 +30,7 @@ from hudi_agent_gateway.tools.registry import ToolInputError
 
 _FORBIDDEN_NODES: tuple[type[exp.Expression], ...] = (
     exp.Insert,
+    exp.Into,
     exp.Update,
     exp.Delete,
     exp.Merge,
@@ -42,31 +43,41 @@ _FORBIDDEN_NODES: tuple[type[exp.Expression], ...] = (
     exp.Command,  # also covers CALL and EXPLAIN, which parse as generic 
commands
     exp.Use,
 )
-
-_HINT = "Provide exactly one read-only SELECT statement (Trino SQL)."
-
-
-def enforce_guardrails(sql: str, row_cap: int) -> str:
+_SPARK_FORBIDDEN_FUNCTIONS = frozenset({"java_method", "reflect", 
"try_reflect", "identifier"})
+_SPARK_HUDI_TABLE_FUNCTIONS = frozenset({
+    "hudi_query", "hudi_table_changes", "hudi_query_timeline", 
"hudi_filesystem_view",
+    "hudi_metadata",
+})
+_SPARK_TABLE_FUNCTIONS = _SPARK_HUDI_TABLE_FUNCTIONS | frozenset({
+    "range", "explode", "explode_outer", "posexplode", "posexplode_outer", 
"inline",
+    "inline_outer", "stack", "json_tuple",
+})
+
+
+def enforce_guardrails(
+    sql: str, row_cap: int, *, dialect: str = "trino", spark_catalog: str = 
"spark_catalog"
+) -> str:
     """Validate ``sql`` as a single read-only SELECT and cap its LIMIT.
 
     Returns the (possibly rewritten) SQL to execute. Raises
     :class:`ToolInputError` on any violation.
     """
+    hint = f"Provide exactly one read-only SELECT statement ({dialect} SQL)."
     try:
-        statements = sqlglot.parse(sql, read="trino")
+        statements = sqlglot.parse(sql, read=dialect)
     except sqlglot.errors.SqlglotError as e:
-        raise ToolInputError(f"could not parse SQL: {e}", hint=_HINT) from e
+        raise ToolInputError(f"could not parse SQL: {e}", hint=hint) from e
 
     statements = [s for s in statements if s is not None]
     if len(statements) != 1:
         raise ToolInputError(
-            f"exactly one statement is allowed, got {len(statements)}", 
hint=_HINT
+            f"exactly one statement is allowed, got {len(statements)}", 
hint=hint
         )
     stmt = statements[0]
 
     if not isinstance(stmt, (exp.Select, exp.SetOperation)):
         raise ToolInputError(
-            f"read-only: only SELECT is allowed, got {type(stmt).__name__}", 
hint=_HINT
+            f"read-only: only SELECT is allowed, got {type(stmt).__name__}", 
hint=hint
         )
 
     # Defense in depth: no write/DDL/command node anywhere in the tree
@@ -74,19 +85,108 @@ def enforce_guardrails(sql: str, row_cap: int) -> str:
     for node in stmt.walk():
         if isinstance(node, _FORBIDDEN_NODES):
             raise ToolInputError(
-                f"read-only: {type(node).__name__} is not allowed", hint=_HINT
+                f"read-only: {type(node).__name__} is not allowed", hint=hint
             )
+        if dialect == "spark":
+            _validate_spark_node(node, spark_catalog=spark_catalog, hint=hint)
+
+    return _cap_limit(stmt, row_cap).sql(dialect=dialect)
+
+
+def _validate_spark_node(node: object, *, spark_catalog: str, hint: str) -> 
None:
+    if isinstance(node, exp.QueryTransform):
+        raise ToolInputError("read-only: Spark script transforms are not 
allowed", hint=hint)
+    if isinstance(node, exp.Table):
+        _validate_spark_table(node, spark_catalog=spark_catalog, hint=hint)
+    elif isinstance(node, exp.Lateral):
+        # LATERAL functions are not necessarily wrapped in an exp.Table.
+        if not isinstance(node.this, exp.Subquery):
+            _validate_spark_table_function(node.this)
+    elif isinstance(node, exp.Func):
+        name = _spark_function_name(node)
+        if name in _SPARK_FORBIDDEN_FUNCTIONS:
+            # Reflection invokes JVM methods; IDENTIFIER hides names from AST 
checks.
+            raise ToolInputError(f"read-only: Spark function {name} is not 
allowed", hint=hint)
+        if name in _SPARK_HUDI_TABLE_FUNCTIONS:
+            # Check arguments at the function node, regardless of its AST 
context.
+            _validate_hudi_table_function(node, name=name)
+
+
+def _validate_spark_table(node: exp.Table, *, spark_catalog: str, hint: str) 
-> None:
+    if not isinstance(node.this, exp.Identifier):
+        _validate_spark_table_function(node.this, qualified=bool(node.db or 
node.catalog))
+        return
+    if any(any(char in part.name for char in "/\\:") for part in node.parts):
+        raise ToolInputError("read-only: Spark file paths are not allowed", 
hint=hint)
+    if not node.catalog and node.db:
+        # Any two-part name can be interpreted as a data source and path,
+        # including custom providers. Force catalog table resolution.
+        node.set("catalog", exp.to_identifier(spark_catalog, quoted=True))
+
+
+def _spark_function_name(node: exp.Func) -> str:
+    return (node.name if isinstance(node, exp.Anonymous) else 
node.sql_name()).lower()
+
+
+def _validate_spark_table_function(node: exp.Expression, *, qualified: bool = 
False) -> None:
+    # Unknown/custom TVFs may read files even with 
spark.sql.runSQLOnFiles=false.
+    name = _spark_function_name(node) if isinstance(node, exp.Func) else 
node.sql(dialect="spark")
+    if qualified or not isinstance(node, exp.Func) or name not in 
_SPARK_TABLE_FUNCTIONS:
+        raise ToolInputError(
+            f"read-only: Spark table function {name} is not allowed",
+            hint="Use an unqualified table function: "
+            + ", ".join(sorted(_SPARK_TABLE_FUNCTIONS)) + ".",
+        )
+
+
+def _validate_hudi_table_function(node: exp.Func, *, name: str) -> None:
+    args = node.expressions
+    hint = f"Pass a literal table name as the first argument to {name}, e.g. 
'default.orders'."
+    _validate_hudi_table_argument(args[0] if args else None, hint=hint)
+    # The optional filesystem-view subpath is appended to the catalog location 
and
+    # can traverse outside it. Only the whole-table form is exposed by the 
gateway.
+    if name == "hudi_filesystem_view" and len(args) != 1:
+        raise ToolInputError(
+            "read-only: Hudi filesystem-view subpaths are not allowed",
+            hint="Use hudi_filesystem_view('default.orders') without a 
subpath.",
+        )
 
-    return _cap_limit(stmt, row_cap).sql(dialect="trino")
+
+def _validate_hudi_table_argument(table: exp.Expression | None, *, hint: str) 
-> None:
+    error = "read-only: Hudi table functions require a literal catalog table 
identifier"
+    # Hudi evaluates expressions before deciding whether the first argument is 
a path.
+    # Reject computed values, including concat/chr/casts, rather than trying 
to fold them.
+    if not isinstance(table, exp.Literal) or not table.is_string:
+        raise ToolInputError(error, hint=hint)
+    if not table.this or any(char in table.this for char in "/\\:"):
+        raise ToolInputError(error, hint=hint)
+    try:
+        identifier = sqlglot.parse_one(table.this, read="spark", 
into=exp.Table)
+    except sqlglot.errors.SqlglotError as e:
+        raise ToolInputError(error, hint=hint) from e
+    if (not all(isinstance(part, exp.Identifier) for part in identifier.parts)
+            or identifier.catalog
+            or any(key not in {"this", "db", "catalog"} for key in 
identifier.args)):
+        raise ToolInputError(error, hint=hint)
 
 
 def _cap_limit(stmt: exp.Query, row_cap: int) -> exp.Query:
     limit = stmt.args.get("limit")
-    if limit is not None:
-        try:
-            existing = int(limit.expression.name)
-        except (TypeError, ValueError):
-            existing = None
-        if existing is not None and existing <= row_cap:
-            return stmt
+    if isinstance(limit, (exp.Limit, exp.Fetch)):
+        count = limit.args.get("count") if isinstance(limit, exp.Fetch) else 
limit.expression
+        if isinstance(limit, exp.Fetch) and count is None:
+            count = exp.Literal.number(1)
+            limit.set("count", count)
+        if isinstance(count, exp.Neg):
+            raise ToolInputError(
+                "row limit must be non-negative",
+                hint="Use LIMIT or FETCH with a non-negative integer.",
+            )
+        options = limit.args.get("limit_options")
+        if options and (options.args.get("with_ties") or 
options.args.get("percent")):
+            # A count with ties or a percentage is not a hard row bound. Keep 
the
+            # original selection semantics, then cap the result in an outer 
query.
+            return 
exp.select("*").from_(stmt.subquery("_guarded_query")).limit(row_cap)
+        if isinstance(count, exp.Literal) and count.is_int:
+            return stmt.limit(min(int(count.this), row_cap))
     return stmt.limit(row_cap)
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/lakehouse_client.py
similarity index 50%
copy from hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py
copy to hudi-agent-gateway/src/hudi_agent_gateway/tools/lakehouse_client.py
index f1d17fd55365..63be3a05a23a 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/mcp_server.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/lakehouse_client.py
@@ -14,28 +14,32 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""The MCP surface: the same tool registry, exposed to external agents."""
+"""Result and error contract shared by the lakehouse backends."""
 
-from __future__ import annotations
+from dataclasses import dataclass, field
+from typing import Any, Protocol
 
-from fastmcp import FastMCP
 
-from hudi_agent_gateway import __version__
-from hudi_agent_gateway.tools.registry import ToolRegistry
+class QueryError(Exception):
+    """A lakehouse query failed."""
 
-_INSTRUCTIONS = (
-    "Tools for querying an Apache Hudi lakehouse through Trino. Use 
list_tables "
-    "and describe_table to discover the schema, then query_lakehouse with a "
-    "single read-only SELECT statement (Trino SQL). Results are row-capped and 
"
-    "may be truncated (indicated in the payload)."
-)
 
+class QueryTimeoutError(QueryError):
+    pass
 
-def build_mcp(registry: ToolRegistry) -> FastMCP:
-    mcp: FastMCP = FastMCP(
-        name="hudi-lakehouse",
-        version=__version__,
-        instructions=_INSTRUCTIONS,
-    )
-    registry.attach_to_mcp(mcp)
-    return mcp
+
+@dataclass
+class QueryResult:
+    columns: list[str]
+    rows: list[list[Any]]
+    row_count: int = field(init=False)
+
+    def __post_init__(self) -> None:
+        self.row_count = len(self.rows)
+
+
+class LakehouseClient(Protocol):
+    async def execute(self, sql: str, *, timeout: float, max_rows: int) -> 
QueryResult: ...
+    async def describe(self, sql: str, *, timeout: float, max_rows: int) -> 
QueryResult: ...
+    async def ping(self, timeout: float = 5.0, cache_seconds: float = 10.0) -> 
bool: ...
+    def close(self) -> None: ...
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/registry.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/registry.py
index 92aa6106af92..6ddda97051c3 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/registry.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/registry.py
@@ -29,6 +29,7 @@ outcome) -- the same trace regardless of which surface called 
the tool.
 
 from __future__ import annotations
 
+import asyncio
 import functools
 import inspect
 import json
@@ -41,6 +42,7 @@ from typing import Any
 from langchain_core.tools import BaseTool, StructuredTool
 from pydantic import BaseModel
 
+from hudi_agent_gateway.cancellation import tool_cancellation_scope
 from hudi_agent_gateway.log import log_event
 
 logger = logging.getLogger("hudi_agent_gateway.tools")
@@ -81,15 +83,24 @@ def _with_invocation_logging(name: str, fn: ToolHandler) -> 
ToolHandler:
     async def wrapped(*args: Any, **kwargs: Any) -> str:
         start = time.monotonic()
         outcome = "ok"
+        scope = tool_cancellation_scope.get()
+        task = asyncio.current_task()
+        if scope is not None and task is not None:
+            scope.add(task)
         try:
             result = await fn(*args, **kwargs)
             if is_tool_error(result):
                 outcome = "tool_error"
             return result
+        except asyncio.CancelledError:
+            outcome = "cancelled"
+            raise
         except Exception:
             outcome = "exception"
             raise
         finally:
+            if scope is not None and task is not None:
+                scope.tasks.discard(task)
             log_event(
                 logger,
                 "tool_invocation",
diff --git 
a/hudi-agent-gateway/src/hudi_agent_gateway/tools/spark_connect_client.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/spark_connect_client.py
new file mode 100644
index 000000000000..247fc972e8e9
--- /dev/null
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/spark_connect_client.py
@@ -0,0 +1,213 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bounded async facade over the official PySpark Connect client.
+
+Each call owns a remote session. Timeout/cancellation requests interruptAll on
+that session off the event loop, but cannot guarantee when Spark stops. 
Capacity
+is retained until the worker finishes, including session cleanup.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass, field
+from typing import Any
+from urllib.parse import unquote, urlparse
+
+from pyspark.errors import PySparkException
+from pyspark.sql.connect.session import SparkSession
+
+from hudi_agent_gateway.config import GatewaySettings
+from hudi_agent_gateway.tools.lakehouse_client import QueryError, QueryResult, 
QueryTimeoutError
+
+
+class _RequestSparkSession(SparkSession):
+    def __del__(self) -> None:
+        # PySpark 4.1 closes even an already-stopped session again during GC.
+        # close() shuts down a process-wide release executor. GC can run while
+        # another thread starts a worker under that executor's lock, 
deadlocking
+        # thread startup and every subsequent executor submission (including 
the
+        # web event loop). Our worker already calls stop() in its finally 
block.
+        client = getattr(self, "_client", None)
+        if client is not None and client.is_closed:
+            return
+        super().__del__()
+
+
+@dataclass
+class _Operation:
+    cancelled: threading.Event = field(default_factory=threading.Event)
+    lock: Any = field(default_factory=threading.Lock)
+    session: SparkSession | None = None
+
+    def cancel(self) -> None:
+        # Serialize cancellation with session removal/close, never with 
execution.
+        with self.lock:
+            if self.session is not None:
+                with contextlib.suppress(Exception):
+                    self.session.interruptAll()
+
+    def check_cancelled(self) -> None:
+        if self.cancelled.is_set():
+            raise QueryError("query cancelled before execution")
+
+
+class SparkConnectClient:
+    def __init__(self, settings: GatewaySettings) -> None:
+        self._settings = settings
+        self._executor = ThreadPoolExecutor(max_workers=8, 
thread_name_prefix="spark-query")
+        self._ping_executor = ThreadPoolExecutor(max_workers=1, 
thread_name_prefix="spark-ping")
+        self._cancel_executor = ThreadPoolExecutor(max_workers=8, 
thread_name_prefix="spark-cancel")
+        # ThreadPoolExecutor's queue is unbounded. Hold a slot until the worker
+        # finishes, even after its async caller timed out.
+        self._slots = threading.BoundedSemaphore(8)
+        self._ping_slot = threading.BoundedSemaphore(1)
+        self._ping_cache: tuple[float, bool] | None = None
+        self._ping_task: asyncio.Task[bool] | None = None
+        self._closed = False
+        self._operations_lock = threading.Lock()
+        self._operations: dict[int, _Operation] = {}
+
+    def close(self) -> None:
+        with self._operations_lock:
+            self._closed = True
+            operations = list(self._operations.values())
+        for op in operations:
+            op.cancelled.set()
+            with contextlib.suppress(RuntimeError):
+                self._cancel_executor.submit(op.cancel)
+        self._executor.shutdown(wait=False, cancel_futures=True)
+        self._ping_executor.shutdown(wait=False, cancel_futures=True)
+        self._cancel_executor.shutdown(wait=False)
+
+    def _execute_sync(
+        self, sql: str, max_rows: int, op: _Operation, describe: bool = False
+    ) -> QueryResult:
+        op.check_cancelled()
+        # Construct directly: getOrCreate() can reuse another request's 
session.
+        spark = 
_RequestSparkSession(connection=self._settings.spark_connect_uri)
+        with op.lock:
+            op.session = spark
+        try:
+            op.check_cancelled()
+            
spark.catalog.setCurrentCatalog(self._settings.spark_connect_catalog)
+            op.check_cancelled()
+            
spark.catalog.setCurrentDatabase(self._settings.spark_connect_schema)
+            op.check_cancelled()
+            df = spark.sql(sql)
+            op.check_cancelled()
+            if describe:
+                return QueryResult(
+                    columns=["column_name", "data_type", "is_nullable"],
+                    rows=[
+                        [f.name, f.dataType.simpleString(), "YES" if 
f.nullable else "NO"]
+                        for f in df.schema.fields[:max_rows]
+                    ],
+                )
+            # Limit before collecting, including metadata queries outside 
guardrails.
+            df = df.limit(max_rows)
+            columns = df.columns
+            op.check_cancelled()
+            return QueryResult(columns=columns, rows=[list(row) for row in 
df.collect()])
+        finally:
+            with op.lock:
+                op.session = None
+            spark.stop()
+
+    async def execute(self, sql: str, *, timeout: float, max_rows: int) -> 
QueryResult:
+        return await self._run(sql, timeout=timeout, max_rows=max_rows)
+
+    async def describe(self, sql: str, *, timeout: float, max_rows: int) -> 
QueryResult:
+        return await self._run(sql, timeout=timeout, max_rows=max_rows, 
describe=True)
+
+    async def _run(
+        self, sql: str, *, timeout: float, max_rows: int, describe: bool = 
False,
+        probe: bool = False,
+    ) -> QueryResult:
+        slots = self._ping_slot if probe else self._slots
+        executor = self._ping_executor if probe else self._executor
+        op = _Operation()
+        with self._operations_lock:
+            if self._closed:
+                raise QueryError("Spark Connect client is closed")
+            if not slots.acquire(blocking=False):
+                raise QueryError(
+                    "Spark Connect query capacity exhausted; retry after 
active queries finish"
+                )
+            try:
+                work = executor.submit(self._execute_sync, sql, max_rows, op, 
describe)
+            except Exception:
+                slots.release()
+                raise
+            self._operations[id(op)] = op
+
+        def release_operation(_: Any) -> None:
+            with self._operations_lock:
+                self._operations.pop(id(op), None)
+            slots.release()
+
+        work.add_done_callback(release_operation)
+        future = asyncio.wrap_future(work)
+        try:
+            return await asyncio.wait_for(future, timeout)
+        except (TimeoutError, asyncio.CancelledError) as e:
+            op.cancelled.set()
+            # No blocking Connect RPC (including interrupt/stop) runs on the 
event loop.
+            with contextlib.suppress(RuntimeError):
+                self._cancel_executor.submit(op.cancel)
+            if isinstance(e, asyncio.CancelledError):
+                raise
+            raise QueryTimeoutError(
+                f"query exceeded the {timeout:g}s timeout; Spark may still be 
running it"
+            ) from e
+        except QueryError:
+            raise
+        except PySparkException as e:
+            message = str(e).replace(self._settings.spark_connect_uri, "[Spark 
Connect endpoint]")
+            uri = urlparse("http" + self._settings.spark_connect_uri[2:])
+            # URI parameters can contain tokens or custom authentication 
headers.
+            for parameter in uri.params.split(";"):
+                _, _, value = parameter.partition("=")
+                if value:
+                    message = message.replace(value, "[redacted]").replace(
+                        unquote(value), "[redacted]"
+                    )
+            raise QueryError(message[:2000]) from e
+        except Exception as e:
+            raise QueryError(f"Spark Connect query failed 
({type(e).__name__})") from e
+
+    async def ping(self, timeout: float = 5.0, cache_seconds: float = 10.0) -> 
bool:
+        if self._ping_cache is not None and time.monotonic() - 
self._ping_cache[0] < cache_seconds:
+            return self._ping_cache[1]
+
+        if self._ping_task is None or self._ping_task.done():
+            self._ping_task = asyncio.create_task(self._probe(timeout))
+        # Cancelling one readiness request must not cancel a shared probe.
+        return await asyncio.shield(self._ping_task)
+
+    async def _probe(self, timeout: float) -> bool:
+        try:
+            await self._run("SELECT 1", timeout=timeout, max_rows=1, 
probe=True)
+            ok = True
+        except Exception:
+            ok = False
+        self._ping_cache = (time.monotonic(), ok)
+        return ok
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_client.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_client.py
index 2eda8730a454..bf35924333e1 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_client.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_client.py
@@ -22,30 +22,20 @@ import asyncio
 import contextlib
 import time
 from concurrent.futures import ThreadPoolExecutor
-from dataclasses import dataclass, field
 from typing import Any
 
 import trino
 
 from hudi_agent_gateway.config import GatewaySettings
-
-
-class TrinoQueryError(Exception):
-    """A query failed on the Trino side; message is safe to surface to the 
model."""
-
-
-class TrinoTimeoutError(TrinoQueryError):
-    pass
-
-
-@dataclass
-class QueryResult:
-    columns: list[str]
-    rows: list[list[Any]]
-    row_count: int = field(init=False)
-
-    def __post_init__(self) -> None:
-        self.row_count = len(self.rows)
+from hudi_agent_gateway.tools.lakehouse_client import (
+    QueryError as TrinoQueryError,
+)
+from hudi_agent_gateway.tools.lakehouse_client import (
+    QueryResult as QueryResult,
+)
+from hudi_agent_gateway.tools.lakehouse_client import (
+    QueryTimeoutError as TrinoTimeoutError,
+)
 
 
 class TrinoClient:
@@ -61,6 +51,9 @@ class TrinoClient:
         self._executor = ThreadPoolExecutor(max_workers=8, 
thread_name_prefix="trino-query")
         self._ping_cache: tuple[float, bool] | None = None
 
+    def close(self) -> None:
+        self._executor.shutdown(wait=False, cancel_futures=True)
+
     def _connect(self) -> trino.dbapi.Connection:
         s = self._settings
         return trino.dbapi.connect(
@@ -105,6 +98,9 @@ class TrinoClient:
         except trino.exceptions.Error as e:
             raise TrinoQueryError(str(e)) from e
 
+    async def describe(self, sql: str, *, timeout: float, max_rows: int) -> 
QueryResult:
+        return await self.execute(sql, timeout=timeout, max_rows=max_rows)
+
     async def ping(self, timeout: float = 5.0, cache_seconds: float = 10.0) -> 
bool:
         """Cached reachability check feeding ``/ready``.
 
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_tools.py 
b/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_tools.py
index 95b6b33082d6..986677116488 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_tools.py
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/tools/trino_tools.py
@@ -14,7 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""Lakehouse tools backed by Trino.
+"""Lakehouse tools backed by Trino or Spark Connect.
 
 Handlers return JSON strings. Expected failures (guardrail rejections, query
 errors, timeouts) are returned as ``{"error": ..., "hint": ...}`` payloads
@@ -32,13 +32,13 @@ from pydantic import Field
 
 from hudi_agent_gateway.config import GatewaySettings
 from hudi_agent_gateway.tools.guardrails import enforce_guardrails
-from hudi_agent_gateway.tools.registry import ToolInputError, ToolRegistry
-from hudi_agent_gateway.tools.trino_client import (
+from hudi_agent_gateway.tools.lakehouse_client import (
+    LakehouseClient,
+    QueryError,
     QueryResult,
-    TrinoClient,
-    TrinoQueryError,
-    TrinoTimeoutError,
+    QueryTimeoutError,
 )
+from hudi_agent_gateway.tools.registry import ToolInputError, ToolRegistry
 
 _QUERY_DESC = (
     "Run a single read-only SELECT statement (Trino SQL) against the lakehouse 
"
@@ -87,15 +87,17 @@ def shape_result(result: QueryResult, *, max_bytes: int, 
sql: str, row_cap: int)
     if len(text.encode()) > max_bytes:
         # Fail-safe: even zero rows can bust the limit (huge sql text, very
         # many columns, or a tiny configured cap). Return a bounded payload.
-        text = json.dumps({
-            "sql": sql[:100],
-            "column_count": len(result.columns),
-            "rows": [],
-            "row_count": result.row_count,
-            "truncated": True,
-            "notice": "Result exceeded the size limit; rows and column names 
omitted. "
-            "Narrow the query or raise the result size limit.",
-        })
+        text = json.dumps(
+            {
+                "sql": sql[:100],
+                "column_count": len(result.columns),
+                "rows": [],
+                "row_count": result.row_count,
+                "truncated": True,
+                "notice": "Result exceeded the size limit; rows and column 
names omitted. "
+                "Narrow the query or raise the result size limit.",
+            }
+        )
         if len(text.encode()) > max_bytes:
             text = json.dumps({"error": "result exceeded the size limit", 
"truncated": True})
     return text
@@ -129,9 +131,9 @@ def _validate_identifier(value: str, kind: str) -> str:
 def _split_table_name(table: str, settings: GatewaySettings) -> tuple[str, 
str, str]:
     parts = table.split(".")
     if len(parts) == 1:
-        return settings.trino_catalog, settings.trino_schema, parts[0]
+        return settings.lakehouse_catalog, settings.lakehouse_schema, parts[0]
     if len(parts) == 2:
-        return settings.trino_catalog, parts[0], parts[1]
+        return settings.lakehouse_catalog, parts[0], parts[1]
     if len(parts) == 3:
         return parts[0], parts[1], parts[2]
     raise ToolInputError(
@@ -139,28 +141,37 @@ def _split_table_name(table: str, settings: 
GatewaySettings) -> tuple[str, str,
     )
 
 
-def register(registry: ToolRegistry, client: TrinoClient, settings: 
GatewaySettings) -> None:
-    @registry.register("query_lakehouse", _QUERY_DESC)
+def register(registry: ToolRegistry, client: LakehouseClient, settings: 
GatewaySettings) -> None:
+    spark = settings.sql_backend == "spark-connect"
+    dialect = settings.sql_dialect
+
+    @registry.register(
+        "query_lakehouse", _QUERY_DESC.replace("Trino", "Spark" if spark else 
"Trino")
+    )
     async def query_lakehouse(
         sql: Annotated[
-            str, Field(description="A single read-only SELECT statement in 
Trino SQL.")
+            str,
+            Field(description="A single read-only SELECT in the configured 
backend's SQL dialect."),
         ],
     ) -> str:
         try:
-            safe_sql = enforce_guardrails(sql, row_cap=settings.sql_row_cap)
+            safe_sql = enforce_guardrails(
+                sql, row_cap=settings.sql_row_cap, dialect=dialect,
+                spark_catalog=settings.spark_connect_catalog,
+            )
             result = await client.execute(
                 safe_sql, timeout=settings.sql_timeout_seconds, 
max_rows=settings.sql_row_cap
             )
         except ToolInputError as e:
             return _error(str(e), e.hint)
-        except TrinoTimeoutError as e:
+        except QueryTimeoutError as e:
             return _error(
                 f"query failed: {e}",
                 f"The query hit the gateway's 
{settings.sql_timeout_seconds:.0f}s limit -- "
                 "narrow it (partition filter, pre-aggregate, fewer columns) or 
raise "
                 "GATEWAY_SQL_TIMEOUT_SECONDS.",
             )
-        except TrinoQueryError as e:
+        except QueryError as e:
             return _error(f"query failed: {e}", "Fix the SQL and try again.")
         return shape_result(
             result,
@@ -179,20 +190,30 @@ def register(registry: ToolRegistry, client: TrinoClient, 
settings: GatewaySetti
         ] = "",
     ) -> str:
         try:
-            cat = _validate_identifier(catalog or settings.trino_catalog, 
"catalog")
-            sch = _validate_identifier(schema_name or settings.trino_schema, 
"schema")
+            cat = _validate_identifier(catalog or settings.lakehouse_catalog, 
"catalog")
+            sch = _validate_identifier(schema_name or 
settings.lakehouse_schema, "schema")
         except ToolInputError as e:
             return _error(str(e), e.hint)
         sql = (
-            f'SELECT table_schema, table_name, table_type FROM 
"{cat}".information_schema.tables '
-            f"WHERE table_schema = '{sch}' ORDER BY table_name"
+            f"SHOW TABLES IN `{cat}`.`{sch}`"
+            if spark
+            else (
+                "SELECT table_schema, table_name, table_type "
+                f'FROM "{cat}".information_schema.tables '
+                f"WHERE table_schema = '{sch}' ORDER BY table_name"
+            )
         )
         try:
             result = await client.execute(
                 sql, timeout=settings.sql_timeout_seconds, 
max_rows=settings.sql_row_cap
             )
-        except TrinoQueryError as e:
+        except QueryError as e:
             return _error(f"listing tables failed: {e}")
+        if spark:
+            result = QueryResult(
+                columns=["table_schema", "table_name", "table_type"],
+                rows=[[row[0], row[1], "TEMPORARY" if row[2] else "TABLE"] for 
row in result.rows],
+            )
         return shape_result(
             result, max_bytes=settings.tool_result_max_bytes, sql=sql, 
row_cap=settings.sql_row_cap
         )
@@ -211,15 +232,19 @@ def register(registry: ToolRegistry, client: TrinoClient, 
settings: GatewaySetti
         except ToolInputError as e:
             return _error(str(e), e.hint)
         sql = (
-            "SELECT column_name, data_type, is_nullable "
-            f'FROM "{cat}".information_schema.columns '
-            f"WHERE table_schema = '{sch}' AND table_name = '{tbl}' ORDER BY 
ordinal_position"
+            f"SELECT * FROM `{cat}`.`{sch}`.`{tbl}` LIMIT 0"
+            if spark
+            else (
+                "SELECT column_name, data_type, is_nullable "
+                f'FROM "{cat}".information_schema.columns '
+                f"WHERE table_schema = '{sch}' AND table_name = '{tbl}' ORDER 
BY ordinal_position"
+            )
         )
         try:
-            result = await client.execute(
+            result = await (client.describe if spark else client.execute)(
                 sql, timeout=settings.sql_timeout_seconds, 
max_rows=settings.sql_row_cap
             )
-        except TrinoQueryError as e:
+        except QueryError as e:
             return _error(f"describe failed: {e}")
         if result.row_count == 0:
             return _error(
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/ui/app.js 
b/hudi-agent-gateway/src/hudi_agent_gateway/ui/app.js
index 8fa4186eccf4..0be5f6635bb1 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/ui/app.js
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/ui/app.js
@@ -27,6 +27,11 @@
   var messagesEl = document.getElementById("messages");
   var welcomeEl = document.getElementById("welcome");
   var inputEl = document.getElementById("input");
+  var activeController = null;
+  var stopEl = document.getElementById("stop");
+  stopEl.addEventListener("click", function () {
+    if (activeController) activeController.abort();
+  });
   var sendEl = document.getElementById("send");
   var composerEl = document.getElementById("composer");
   var modelInfoEl = document.getElementById("model-info");
@@ -49,7 +54,10 @@
   fetch("../v1/info").then(function (r) { return r.json(); }).then(function 
(info) {
     modelInfoEl.textContent = info.provider + " · catalog " + info.catalog;
     var base = window.location.origin;
-    document.getElementById("ep-trino").textContent = info.trino_url;
+    document.getElementById("ep-trino").textContent = info.lakehouse_url || 
info.trino_url;
+    document.getElementById("ep-sql-label").textContent =
+      info.sql_backend === "spark-connect" ? "Spark Connect" : "Trino SQL";
+    document.getElementById("ep-trino-ui-row").hidden = !info.trino_ui_url;
     var trinoUi = document.getElementById("ep-trino-ui");
     trinoUi.textContent = info.trino_ui_url;
     trinoUi.href = info.trino_ui_url;
@@ -158,6 +166,8 @@
   function setBusy(value) {
     busy = value;
     sendEl.disabled = value;
+    stopEl.hidden = !value;
+    document.getElementById("clear-chat").disabled = value;
   }
 
   function send(text) {
@@ -165,6 +175,25 @@
     setBusy(true);
 
     var msg = el("div", "msg assistant", messagesEl);
+    var status = el("div", "response-status", msg);
+    status.setAttribute("role", "status");
+    var phase = "Connecting…";
+    var started = Date.now();
+    function updateStatus() {
+      status.textContent = phase + " · " + Math.floor((Date.now() - started) / 
1000) + "s";
+    }
+    updateStatus();
+    var timer = setInterval(updateStatus, 1000);
+    var controller = new AbortController();
+    activeController = controller;
+    var connectionTimedOut = false;
+    var connectionTimer = setTimeout(function () {
+      connectionTimedOut = true;
+      controller.abort();
+    }, 15000);
+    var terminal = false;
+    var failed = false;
+    var stopped = false;
     var bubble = el("div", "bubble md", msg);
     var answer = "";
     var cursor = el("span", "cursor", bubble);
@@ -181,8 +210,10 @@
     fetch("../v1/chat", {
       method: "POST",
       headers: { "Content-Type": "application/json", "Accept": 
"text/event-stream" },
+      signal: controller.signal,
       body: JSON.stringify(payload)
     }).then(function (resp) {
+      clearTimeout(connectionTimer);
       if (!resp.ok || !resp.body) {
         return resp.text().then(function (body) {
           throw new Error("gateway returned " + resp.status + ": " + 
body.slice(0, 300));
@@ -194,7 +225,10 @@
 
       function pump() {
         return reader.read().then(function (step) {
-          if (step.done) { finish(); return null; }
+          if (step.done) {
+            if (!terminal) throw new Error("Connection closed before the 
response completed.");
+            return null;
+          }
           // Normalize SSE line endings (servers may emit \r\n) before framing.
           buffer += decoder.decode(step.value, { stream: true 
}).replace(/\r/g, "");
           var frames = buffer.split("\n\n");
@@ -205,9 +239,16 @@
       }
       return pump();
     }).catch(function (err) {
-      addErrorBanner(err.message || String(err));
-      finish();
-    });
+      if (connectionTimedOut) {
+        failed = true;
+        addErrorBanner("Gateway did not respond within 15s. Check the service 
connection and try again.");
+      } else if (err.name === "AbortError") {
+        stopped = true;
+      } else {
+        failed = true;
+        addErrorBanner(err.message || String(err));
+      }
+    }).finally(finish);
 
     function handleFrame(frame) {
       var event = "message";
@@ -220,10 +261,16 @@
       var data;
       try { data = JSON.parse(dataLines.join("\n")); } catch (e) { return; }
 
-      if (event === "token") {
+      if (event === "status") {
+        phase = data.message;
+        updateStatus();
+      } else if (event === "token") {
+        phase = "Generating answer…";
         answer += data.text;
         renderAnswer();
       } else if (event === "tool_call") {
+        phase = "Running " + data.name + "…";
+        updateStatus();
         var chip = el("details", "tool-chip running", bubble.parentNode);
         bubble.parentNode.insertBefore(chip, bubble);
         var summary = el("summary", "", chip);
@@ -233,6 +280,8 @@
         chips[data.id || data.name] = chip;
         scrollToBottom();
       } else if (event === "tool_result") {
+        phase = "Waiting for model…";
+        updateStatus();
         var target = chips[data.id || data.name];
         if (target) {
           target.classList.remove("running");
@@ -244,8 +293,11 @@
         }
         scrollToBottom();
       } else if (event === "error") {
+        terminal = true;
+        failed = true;
         addErrorBanner(data.code + ": " + data.message);
       } else if (event === "done") {
+        terminal = true;
         if (!answer && data.message) {
           answer = data.message;
           renderAnswer();
@@ -254,10 +306,23 @@
     }
 
     function finish() {
+      clearTimeout(connectionTimer);
+      clearInterval(timer);
+      activeController = null;
+      phase = stopped ? "Stopped" : failed ? "Failed" : "Complete";
+      updateStatus();
+      if (stopped || failed) {
+        // A cancelled turn can leave unmatched tool calls in checkpoint 
history.
+        sessionId = newSession();
+        status.textContent += " · Next message starts a new conversation.";
+      }
+      msg.querySelectorAll(".tool-chip.running").forEach(function (chip) {
+        chip.classList.remove("running");
+      });
       setBusy(false);
       if (cursor.parentNode) cursor.remove();
       if (!answer && !msg.querySelector(".tool-chip")) {
-        bubble.innerHTML = "<p><em>(no response)</em></p>";
+        bubble.textContent = stopped ? "Response stopped." : failed ? 
"Response failed." : "(no response)";
       } else {
         bubble.innerHTML = window.HudiMarkdown.render(answer);
       }
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/ui/index.html 
b/hudi-agent-gateway/src/hudi_agent_gateway/ui/index.html
index d924ce4d047f..7cb68bed5d62 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/ui/index.html
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/ui/index.html
@@ -42,13 +42,13 @@ limitations under the License.
   <section id="connect-panel" hidden>
     <div class="endpoint">
       <div>
-        <strong>Trino SQL</strong>
+        <strong id="ep-sql-label">Lakehouse SQL</strong>
         <small>point any SQL client / BI tool at the lakehouse directly</small>
       </div>
       <code id="ep-trino">&hellip;</code>
       <button class="copy" data-copy="ep-trino" title="Copy">&#x2398;</button>
     </div>
-    <div class="endpoint">
+    <div class="endpoint" id="ep-trino-ui-row">
       <div>
         <strong>Trino Web UI</strong>
         <small>query monitoring &amp; cluster overview</small>
@@ -72,7 +72,7 @@ limitations under the License.
       <code id="ep-mcp">&hellip;</code>
       <button class="copy" data-copy="ep-mcp" title="Copy">&#x2398;</button>
     </div>
-    <p class="endpoint-note">Trino is addressed by its in-cluster service 
name; from outside the
+    <p class="endpoint-note">The SQL endpoint is addressed by its in-cluster 
service name; from outside the
     cluster, port-forward or expose the service. The inference and MCP URLs 
are this gateway.</p>
   </section>
 
@@ -80,7 +80,7 @@ limitations under the License.
     <div class="welcome" id="welcome">
       <h1>Talk to your lakehouse</h1>
       <p>Ask questions about your Hudi tables in plain English. The agent
-      inspects the catalog, writes guarded Trino SQL, and answers from real
+      inspects the catalog, writes guarded read-only SQL, and answers from real
       query results.</p>
       <div class="suggestions">
         <button class="suggestion">What tables are available?</button>
@@ -94,6 +94,7 @@ limitations under the License.
     <form id="composer">
       <textarea id="input" rows="1" placeholder="Ask about your data&hellip;"
                 autocomplete="off" autofocus></textarea>
+      <button id="stop" type="button" hidden>Stop</button>
       <button id="send" type="submit" title="Send">&#x27A4;</button>
     </form>
     <p class="fineprint">Answers are generated from live queries against your 
lakehouse. Verify before acting.</p>
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/ui/style.css 
b/hudi-agent-gateway/src/hudi_agent_gateway/ui/style.css
index b4c0e5e129a3..5340084627a5 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/ui/style.css
+++ b/hudi-agent-gateway/src/hudi_agent_gateway/ui/style.css
@@ -258,3 +258,7 @@ footer { background: var(--panel); border-top: 1px solid 
var(--border); padding:
 }
 #send:disabled { opacity: 0.45; cursor: default; }
 .fineprint { text-align: center; color: var(--muted); font-size: 11.5px; 
margin: 8px 0 0; }
+
+.response-status { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
+#stop { border: 1px solid var(--border); border-radius: 10px; padding: 10px 
14px;
+  background: var(--bg); color: var(--text); cursor: pointer; }
diff --git a/hudi-agent-gateway/tests/conftest.py 
b/hudi-agent-gateway/tests/conftest.py
index 66b8981bb5ba..7f8d64127e3e 100644
--- a/hudi-agent-gateway/tests/conftest.py
+++ b/hudi-agent-gateway/tests/conftest.py
@@ -63,7 +63,13 @@ class FakeTrinoClient:
             raise self.error
         return self.result
 
-    async def ping(self, timeout: float = 5.0) -> bool:
+    async def describe(self, sql: str, *, timeout: float, max_rows: int) -> 
QueryResult:
+        return await self.execute(sql, timeout=timeout, max_rows=max_rows)
+
+    def close(self) -> None:
+        pass
+
+    async def ping(self, timeout: float = 5.0, cache_seconds: float = 10.0) -> 
bool:
         return self.error is None
 
     def fail_with(self, message: str) -> None:
@@ -77,7 +83,7 @@ def fake_trino() -> FakeTrinoClient:
 
 @pytest.fixture()
 def registry(settings: GatewaySettings, fake_trino: FakeTrinoClient):
-    return build_registry(settings, trino_client=fake_trino)  # type: 
ignore[arg-type]
+    return build_registry(settings, lakehouse_client=fake_trino)  # type: 
ignore[arg-type]
 
 
 class ScriptedChatModel(GenericFakeChatModel):
diff --git a/hudi-agent-gateway/tests/integration/test_spark_connect_live.py 
b/hudi-agent-gateway/tests/integration/test_spark_connect_live.py
new file mode 100644
index 000000000000..7d961667ff41
--- /dev/null
+++ b/hudi-agent-gateway/tests/integration/test_spark_connect_live.py
@@ -0,0 +1,84 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Read-only tests on an existing table in the Helm Connect endpoint.
+
+Set GATEWAY_IT_SPARK_CONNECT_URI and GATEWAY_IT_SPARK_TABLE (schema.table).
+GATEWAY_IT_LLM=1 also enables the real Agent loop. No fixture writes here.
+"""
+
+import json
+import os
+
+import pytest
+from fastmcp import Client
+
+from hudi_agent_gateway.config import GatewaySettings
+from hudi_agent_gateway.mcp_server import build_mcp
+from hudi_agent_gateway.tools import build_client, build_registry
+
+URI = os.environ.get("GATEWAY_IT_SPARK_CONNECT_URI", "")
+TABLE = os.environ.get("GATEWAY_IT_SPARK_TABLE", "")
+pytestmark = pytest.mark.skipif(
+    not URI or not TABLE, reason="set Connect URI and existing test table"
+)
+
+
[email protected]()
+def connect_registry():
+    s = GatewaySettings(
+        sql_backend="spark-connect",
+        spark_connect_uri=URI,
+    )
+    client = build_client(s)
+    try:
+        yield s, build_registry(s, client)
+    finally:
+        client.close()
+
+
+async def test_tools_and_mcp_on_connect(connect_registry):
+    _, registry = connect_registry
+    schema, table = TABLE.split(".")
+    described = json.loads(await 
registry.get("describe_table").handler(table=TABLE))
+    assert "error" not in described and described["row_count"] > 0
+    tables = json.loads(await 
registry.get("list_tables").handler(schema_name=schema))
+    assert "error" not in tables and table in [row[1] for row in 
tables["rows"]]
+    # describe_table validates identifiers before the SELECT is constructed.
+    sql = f"SELECT count(*) AS rows FROM `{schema}`.`{table}`"
+    out = json.loads(await registry.get("query_lakehouse").handler(sql=sql))
+    assert "error" not in out and out["row_count"] == 1
+    async with Client(build_mcp(registry)) as mcp:
+        result = await mcp.call_tool("query_lakehouse", {"sql": sql})
+        assert json.loads(result.content[0].text)["rows"] == out["rows"]
+        result = await mcp.call_tool("query_lakehouse", {"sql": f"DELETE FROM 
{TABLE}"})
+        assert "read-only" in json.loads(result.content[0].text)["error"]
+
+
[email protected](os.environ.get("GATEWAY_IT_LLM") != "1", reason="set 
GATEWAY_IT_LLM=1")
+async def test_agent_on_connect(connect_registry):
+    from langgraph.checkpoint.memory import InMemorySaver
+
+    from hudi_agent_gateway.agent import build_agent
+    from hudi_agent_gateway.llm import build_chat_model
+
+    s, registry = connect_registry
+    agent = build_agent(build_chat_model(s), registry, InMemorySaver(), s)
+    out = await agent.ainvoke(
+        {"messages": [("user", f"Describe the columns of {TABLE}, using your 
tools.")]},
+        config={"configurable": {"thread_id": "connect-it"}},
+    )
+    assert any(getattr(m, "name", None) == "describe_table" for m in 
out["messages"])
diff --git a/hudi-agent-gateway/tests/test_agent_loop.py 
b/hudi-agent-gateway/tests/test_agent_loop.py
index 935497212d57..154beec3ed11 100644
--- a/hudi-agent-gateway/tests/test_agent_loop.py
+++ b/hudi-agent-gateway/tests/test_agent_loop.py
@@ -42,6 +42,20 @@ async def test_loop_executes_tool_and_answers(settings, 
registry, fake_trino) ->
     assert "100 trips" in messages[-1].text()
 
 
+async def test_spark_agent_loop_calls_guarded_tool(settings, fake_trino) -> 
None:
+    from hudi_agent_gateway.tools import build_registry
+
+    spark = settings.model_copy(update={"sql_backend": "spark-connect"})
+    registry = build_registry(spark, fake_trino)
+    agent = build_agent(scripted_model(TOOL_CALL_THEN_ANSWER), registry, 
InMemorySaver(), spark)
+    result = await agent.ainvoke(
+        {"messages": [HumanMessage("How many trips per city?")]},
+        config={"configurable": {"thread_id": "spark"}},
+    )
+    assert fake_trino.executed and "LIMIT 100" in fake_trino.executed[0]
+    assert any(isinstance(message, ToolMessage) for message in 
result["messages"])
+
+
 async def test_checkpointer_preserves_history_across_turns(settings, registry) 
-> None:
     saver = InMemorySaver()
     first = scripted_model(
diff --git a/hudi-agent-gateway/tests/test_api_meta.py 
b/hudi-agent-gateway/tests/test_api_meta.py
index 0aeba0c9dc26..487178a8af0c 100644
--- a/hudi-agent-gateway/tests/test_api_meta.py
+++ b/hudi-agent-gateway/tests/test_api_meta.py
@@ -29,7 +29,7 @@ async def client(settings, fake_trino, monkeypatch):
     async with httpx.AsyncClient(
         transport=httpx.ASGITransport(app=app), base_url="http://test";
     ) as c, app.router.lifespan_context(app):
-        app.state.trino_client = fake_trino
+        app.state.lakehouse_client = fake_trino
         yield c, app
 
 
@@ -42,7 +42,7 @@ async def test_health(client) -> None:
 
 async def test_ready_reports_dependency_failures(client) -> None:
     c, app = client
-    app.state.trino_client.fail_with("connection refused")
+    app.state.lakehouse_client.fail_with("connection refused")
 
     class NotReady:
         async def check(self):
diff --git a/hudi-agent-gateway/tests/test_chat_sse.py 
b/hudi-agent-gateway/tests/test_chat_sse.py
index 92f68bbdbcc4..e335b5f8b5b5 100644
--- a/hudi-agent-gateway/tests/test_chat_sse.py
+++ b/hudi-agent-gateway/tests/test_chat_sse.py
@@ -18,11 +18,12 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 
 import httpx
 import pytest
-from langchain_core.messages import AIMessage
+from langchain_core.messages import AIMessage, AIMessageChunk
 
 from hudi_agent_gateway.agent import build_agent
 from hudi_agent_gateway.app import create_app
@@ -132,3 +133,143 @@ async def test_validation_rejects_empty_message(client) 
-> None:
     c, _ = client
     resp = await c.post("/v1/chat", json={"message": "", "session_id": "s"})
     assert resp.status_code == 422
+
+
[email protected]("streaming", [False, True])
+async def test_whole_turn_timeout_cancels_active_model(client, streaming):
+    c, app = client
+    app.state.settings.chat_timeout_seconds = 0.05
+    cancelled = asyncio.Event()
+
+    class EndlessAgent:
+        async def ainvoke(self, *args, **kwargs):
+            try:
+                await asyncio.Event().wait()
+            finally:
+                cancelled.set()
+
+        async def astream(self, *args, **kwargs):
+            try:
+                while True:
+                    # Active token delivery must not reset the total deadline.
+                    yield "messages", (AIMessageChunk(content="x"), {})
+                    await asyncio.sleep(0.005)
+            finally:
+                cancelled.set()
+
+    app.state.agents = FixedAgents(EndlessAgent())
+    async with asyncio.timeout(2):
+        response = await c.post("/v1/chat", json={
+            "message": "test", "session_id": "deadline", "stream": streaming,
+        })
+    assert cancelled.is_set()
+    if streaming:
+        events = _parse_sse(response.text)
+        assert events[0][0] == "status"
+        assert any(kind == "token" for kind, _ in events)
+        assert events[-1][1]["code"] == "chat_timeout"
+        assert "Start a new session" in events[-1][1]["message"]
+        assert all(kind != "done" for kind, _ in events)
+    else:
+        assert response.status_code == 504
+        assert "Start a new session" in response.json()["detail"]
+
+
+async def test_disconnected_sse_cancels_agent(client):
+    from starlette.requests import Request
+
+    c, app = client
+    started, cancelled = asyncio.Event(), asyncio.Event()
+
+    class WaitingAgent:
+        async def astream(self, *args, **kwargs):
+            started.set()
+            try:
+                await asyncio.Event().wait()
+                yield "updates", {}
+            finally:
+                cancelled.set()
+
+    from hudi_agent_gateway.api.chat import chat
+    from hudi_agent_gateway.api.models import ChatRequest
+
+    app.state.agents = FixedAgents(WaitingAgent())
+    request = Request({"type": "http", "method": "POST", "path": "/v1/chat",
+                       "headers": [], "app": app})
+    response = await chat(request, ChatRequest(message="test", 
session_id="cancel", stream=True))
+
+    async def receive():
+        await started.wait()
+        return {"type": "http.disconnect"}
+
+    async def send(message):
+        pass
+
+    async with asyncio.timeout(2):
+        await response(request.scope, receive, send)
+    assert cancelled.is_set()
+
+
+async def test_disconnected_sse_cancels_real_agent_tool(client, settings):
+    from starlette.requests import Request
+
+    from hudi_agent_gateway.api.chat import chat
+    from hudi_agent_gateway.api.models import ChatRequest
+    from hudi_agent_gateway.tools import build_registry
+
+    _, app = client
+    started, cancelled = asyncio.Event(), asyncio.Event()
+
+    class WaitingClient:
+        async def execute(self, *args, **kwargs):
+            started.set()
+            try:
+                await asyncio.Event().wait()
+            finally:
+                cancelled.set()
+
+    registry = build_registry(settings, lakehouse_client=WaitingClient())
+    app.state.agents = FixedAgents(build_agent(
+        scripted_model(TOOL_CALL_THEN_ANSWER), registry, 
app.state.sessions.checkpointer, settings
+    ))
+    request = Request({"type": "http", "method": "POST", "path": "/v1/chat",
+                       "headers": [], "app": app})
+    response = await chat(
+        request, ChatRequest(message="test", session_id="real-cancel", 
stream=True)
+    )
+
+    async def receive():
+        await started.wait()
+        return {"type": "http.disconnect"}
+
+    async def send(message):
+        pass
+
+    async with asyncio.timeout(3):
+        await response(request.scope, receive, send)
+        await cancelled.wait()
+
+
+async def test_cancellation_scope_isolates_turns_and_rejects_late_tools():
+    from hudi_agent_gateway.cancellation import ToolCancellationScope
+
+    first, second = ToolCancellationScope(), ToolCancellationScope()
+    started = asyncio.Event()
+
+    async def wait():
+        started.set()
+        await asyncio.Event().wait()
+
+    first_task, second_task = asyncio.create_task(wait()), 
asyncio.create_task(wait())
+    first.add(first_task)
+    second.add(second_task)
+    await started.wait()
+    first.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await first_task
+    assert not second_task.done()
+    with pytest.raises(asyncio.CancelledError):
+        first.add(asyncio.current_task())
+    second.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await second_task
diff --git a/hudi-agent-gateway/tests/test_guardrails.py 
b/hudi-agent-gateway/tests/test_guardrails.py
index d7c610c511b1..ff4b04c1a1c3 100644
--- a/hudi-agent-gateway/tests/test_guardrails.py
+++ b/hudi-agent-gateway/tests/test_guardrails.py
@@ -19,6 +19,8 @@
 from __future__ import annotations
 
 import pytest
+import sqlglot
+from sqlglot import exp
 
 from hudi_agent_gateway.tools.guardrails import enforce_guardrails
 from hudi_agent_gateway.tools.registry import ToolInputError
@@ -41,6 +43,56 @@ def test_oversized_limit_is_lowered() -> None:
     assert f"LIMIT {CAP}" in out and "100000" not in out
 
 
[email protected]("dialect", ["trino", "spark"])
[email protected]("count", [0, 1, CAP, CAP + 1])
+def test_limit_boundaries(dialect: str, count: int) -> None:
+    out = enforce_guardrails(f"SELECT a FROM t LIMIT {count}", CAP, 
dialect=dialect)
+    stmt = sqlglot.parse_one(out, read=dialect)
+    assert int(stmt.args["limit"].expression.this) == min(count, CAP)
+
+
[email protected]("clause, expected", [
+    ("FETCH FIRST 10 ROWS ONLY", 10),
+    ("FETCH NEXT 1000 ROWS ONLY", CAP),
+    ("FETCH FIRST ROW ONLY", 1),
+    ("FETCH FIRST 0 ROWS ONLY", 0),
+])
+def test_fetch_is_normalized_and_capped(clause: str, expected: int) -> None:
+    out = enforce_guardrails(f"SELECT a FROM t ORDER BY a OFFSET 2 ROWS 
{clause}", CAP)
+    stmt = sqlglot.parse_one(out, read="trino")
+    assert isinstance(stmt.args["limit"], exp.Limit)
+    assert int(stmt.args["limit"].expression.this) == expected
+    assert stmt.args["offset"].expression.this == "2"
+    assert stmt.args["order"] is not None
+
+
[email protected]("count, expected", [("10", 10), ("1000", 1000), ("", 
1)])
+def test_fetch_with_ties_keeps_inner_selection_and_has_outer_cap(count: str, 
expected: int) -> None:
+    sql = f"SELECT a FROM t ORDER BY a OFFSET 2 ROWS FETCH FIRST {count} ROWS 
WITH TIES"
+    out = enforce_guardrails(sql, CAP)
+    stmt = sqlglot.parse_one(out, read="trino")
+    assert int(stmt.args["limit"].expression.this) == CAP
+    inner = stmt.find(exp.Subquery).this
+    assert isinstance(inner.args["limit"], exp.Fetch)
+    assert int(inner.args["limit"].args["count"].this) == expected
+    assert inner.args["limit"].args["limit_options"].args["with_ties"]
+    assert inner.args["offset"].expression.this == "2"
+    assert inner.args["order"] is not None
+
+
+def test_union_fetch_with_ties_caps_the_whole_result() -> None:
+    sql = "SELECT a FROM t UNION ALL SELECT a FROM u ORDER BY a FETCH FIRST 10 
ROWS WITH TIES"
+    stmt = sqlglot.parse_one(enforce_guardrails(sql, CAP), read="trino")
+    assert int(stmt.args["limit"].expression.this) == CAP
+    assert isinstance(stmt.find(exp.Subquery).this, exp.Union)
+
+
[email protected]("dialect", ["trino", "spark"])
+def test_negative_limit_is_rejected(dialect: str) -> None:
+    with pytest.raises(ToolInputError, match="non-negative"):
+        enforce_guardrails("SELECT a FROM t LIMIT -1", CAP, dialect=dialect)
+
+
 def test_cte_select_is_allowed() -> None:
     out = enforce_guardrails("WITH x AS (SELECT a FROM t) SELECT * FROM x", 
row_cap=CAP)
     assert out.upper().startswith("WITH") and f"LIMIT {CAP}" in out
@@ -102,3 +154,165 @@ def test_string_literal_containing_dml_is_fine() -> None:
 def test_quoted_identifiers_round_trip() -> None:
     out = enforce_guardrails('SELECT "weird col" FROM "my table"', row_cap=CAP)
     assert '"weird col"' in out
+
+
[email protected]("sql", [
+    "SELECT TRANSFORM(a) USING 'sh -c id' AS (x) FROM t",
+    "SELECT * FROM text.`file:///proc/self/environ`",
+    "SELECT * FROM json.`/var/run/secrets/token`",
+    "SELECT * FROM parquet.`s3a://other-bucket/path`",
+    "WITH x AS (SELECT * FROM text.`file:///etc/passwd`) SELECT * FROM x",
+    "SELECT * FROM (SELECT * FROM parquet.`s3a://bucket/path`) x",
+    "SELECT * FROM hudi_table_changes('s3a://other-bucket/tbl', 
'latest_state', 'earliest')",
+    "SELECT * FROM hudi_query_timeline('s3a://other-bucket/tbl', 'false')",
+    "SELECT * FROM IDENTIFIER('text.`/etc/passwd`')",
+])
+def test_spark_script_and_path_reads_are_rejected(sql: str) -> None:
+    with pytest.raises(ToolInputError):
+        enforce_guardrails(sql, row_cap=CAP, dialect="spark")
+
+
[email protected]("sql", [
+    "SELECT * FROM `2024`",
+    "SELECT * FROM `schema with space`.`my-table`",
+    "SELECT * FROM text.orders",
+    "SELECT * FROM `json`.`orders`",
+    "SELECT * FROM other.text.orders",
+])
+def test_spark_catalog_identifiers_are_allowed(sql: str) -> None:
+    out = enforce_guardrails(sql, row_cap=CAP, dialect="spark", 
spark_catalog="my_catalog")
+    assert "LIMIT 100" in out
+    if sql in ("SELECT * FROM text.orders", "SELECT * FROM `json`.`orders`",
+               "SELECT * FROM `schema with space`.`my-table`"):
+        assert "`my_catalog`." in out
+
+
+def test_spark_relative_datasource_path_cannot_use_file_fallback() -> None:
+    out = enforce_guardrails("SELECT * FROM text.`relative-file`", CAP, 
dialect="spark")
+    assert "`spark_catalog`.text." in out
+
+
[email protected]("sql", [
+    "SELECT * FROM xml.`secrets`",
+    "SELECT * FROM libsvm.`secrets`",
+    "SELECT * FROM 
`org.apache.spark.sql.execution.datasources.json`.`secrets`",
+    "SELECT * FROM custom_provider.`*`",
+    "WITH x AS (SELECT * FROM xml.`secrets`) SELECT * FROM x",
+    "SELECT * FROM (SELECT * FROM custom_provider.`relative`) x",
+])
+def test_spark_all_two_part_tables_are_catalog_qualified(sql: str) -> None:
+    out = enforce_guardrails(sql, CAP, dialect="spark", 
spark_catalog="my_catalog")
+    tables = list(sqlglot.parse_one(out, read="spark").find_all(exp.Table))
+    assert any(table.catalog == "my_catalog" for table in tables)
+    assert all(not table.db or table.catalog for table in tables)
+
+
+def test_spark_qualified_tables_and_allowed_table_functions_are_preserved() -> 
None:
+    out = enforce_guardrails(
+        "SELECT * FROM other.text.orders JOIN range(1) ON true", CAP, 
dialect="spark"
+    )
+    assert "other.text.orders" in out
+    assert "RANGE(1)" in out
+
+
+_HUDI_FUNCTION_CALLS = [
+    "hudi_query({table}, 'snapshot')",
+    "hudi_table_changes({table}, 'latest_state', 'earliest')",
+    "hudi_query_timeline({table}, 'false')",
+    "hudi_filesystem_view({table})",
+    "hudi_metadata({table})",
+]
+
+
[email protected]("call", _HUDI_FUNCTION_CALLS)
[email protected]("table", [
+    "'s3a://other-bucket/tbl'", "'/etc/passwd'", "'../tbl'", "'file:tbl'",
+    r"'C:\\data\\tbl'", "'`db`.`/tmp/tbl`'", "'db.tbl extra'", "'range(1)'",
+    "concat('s3a:', '//other-bucket/tbl')", "'s3a:' || '//other-bucket/tbl'",
+    "concat(chr(47), 'tmp', chr(47), 'tbl')", "CAST('/tmp/tbl' AS STRING)",
+    "(SELECT '/tmp/tbl')", "NULL", "table_name", "''",
+])
+def test_spark_hudi_table_arguments_are_checked(call: str, table: str) -> None:
+    with pytest.raises(ToolInputError, match="literal catalog table 
identifier"):
+        enforce_guardrails(f"SELECT * FROM {call.format(table=table)}", CAP, 
dialect="spark")
+
+
[email protected]("call", _HUDI_FUNCTION_CALLS)
[email protected]("table", ["orders", "default.orders", "`my 
db`.`my``table`"])
+def test_spark_hudi_catalog_tables_are_allowed(call: str, table: str) -> None:
+    sql = "SELECT * FROM " + call.format(table=f"'{table}'")
+    out = enforce_guardrails(sql, CAP, dialect="spark")
+    assert table in out and "LIMIT 100" in out
+
+
[email protected]("call", [
+    "HuDi_Query_Timeline('/tmp/tbl', 'false')",
+    "`hudi_metadata`('/tmp/tbl')",
+    "hudi_filesystem_view('default.orders', '../other-table')",
+    "hudi_filesystem_view('default.orders', concat('..', '/other-table'))",
+    "hudi_metadata()",
+    "IdEnTiFiEr(concat('text.', '`/etc/passwd`'))",
+    "`identifier`('text.`/etc/passwd`')",
+    "custom_reader(concat('s3a:', '//other-bucket/tbl'))",
+    "schema.hudi_metadata('/tmp/tbl')",
+    "schema.range(1)",
+])
[email protected]("query", [
+    "SELECT * FROM {call}",
+    "WITH x AS (SELECT * FROM {call}) SELECT * FROM x",
+    "SELECT * FROM (SELECT * FROM {call}) x",
+    "SELECT * FROM t JOIN {call} ON true",
+    "SELECT * FROM t, LATERAL {call}",
+    "SELECT * FROM t LATERAL VIEW {call} x AS y",
+])
+def test_spark_unsafe_table_functions_are_rejected_everywhere(call: str, 
query: str) -> None:
+    with pytest.raises(ToolInputError):
+        enforce_guardrails(query.format(call=call), CAP, dialect="spark")
+
+
[email protected]("sql", [
+    "SELECT IDENTIFIER('col') FROM t",
+    "SELECT builtin.identifier('col') FROM t",
+    "SELECT * FROM t WHERE IDENTIFIER('col') = 1",
+])
+def test_spark_dynamic_identifiers_are_rejected_outside_from(sql: str) -> None:
+    with pytest.raises(ToolInputError, match="Spark function identifier"):
+        enforce_guardrails(sql, CAP, dialect="spark")
+
+
[email protected]("sql", [
+    "SELECT '/etc/passwd', 's3a://bucket/tbl', 'IDENTIFIER(x)', `identifier` 
FROM t",
+    "SELECT * FROM range(10)",
+    "SELECT * FROM explode(array('a/b', 'file:/tmp'))",
+    "SELECT * FROM explode_outer(array(1))",
+    "SELECT * FROM posexplode(array(1))",
+    "SELECT * FROM posexplode_outer(array(1))",
+    "SELECT * FROM inline(array(struct(1)))",
+    "SELECT * FROM inline_outer(array(struct(1)))",
+    "SELECT * FROM stack(1, 2)",
+    "SELECT * FROM json_tuple('{\"path\":\"a/b\"}', 'path')",
+    "SELECT * FROM t LATERAL VIEW explode(array('a/b')) x AS y",
+    "SELECT * FROM t, LATERAL hudi_query_timeline('default.orders', 'false')",
+    "SELECT * FROM t, LATERAL (SELECT * FROM range(1)) x",
+])
+def test_spark_safe_functions_and_path_literals_are_allowed(sql: str) -> None:
+    assert "LIMIT 100" in enforce_guardrails(sql, CAP, dialect="spark")
+
+
+def test_trino_table_functions_are_unchanged() -> None:
+    out = enforce_guardrails("SELECT * FROM 
schema.custom_reader('s3a://bucket/tbl')", CAP)
+    assert "CUSTOM_READER" in out and "LIMIT 100" in out
+
+
[email protected]("sql, name, hint", [
+    ("SELECT * FROM custom_reader('x')", "custom_reader", "unqualified table 
function"),
+    ("SELECT * FROM schema.range(1)", "range", "unqualified table function"),
+    ("SELECT * FROM hudi_metadata(concat('db.', 't'))", "Hudi", 
"hudi_metadata"),
+    ("SELECT * FROM hudi_filesystem_view('db.t', '../x')", "subpaths", 
"without a subpath"),
+])
+def test_spark_function_errors_explain_how_to_correct_the_query(
+    sql: str, name: str, hint: str,
+) -> None:
+    with pytest.raises(ToolInputError, match=name) as error:
+        enforce_guardrails(sql, CAP, dialect="spark")
+    assert hint in error.value.hint
diff --git a/hudi-agent-gateway/tests/test_mcp.py 
b/hudi-agent-gateway/tests/test_mcp.py
index f1455acc8b83..b7f81c609bb1 100644
--- a/hudi-agent-gateway/tests/test_mcp.py
+++ b/hudi-agent-gateway/tests/test_mcp.py
@@ -46,7 +46,7 @@ async def test_mounted_mcp_serves_over_http(settings, 
fake_trino) -> None:
     async with httpx.AsyncClient(
         transport=httpx.ASGITransport(app=app), base_url="http://test";
     ) as c, app.router.lifespan_context(app):
-        app.state.trino_client = fake_trino
+        app.state.lakehouse_client = fake_trino
         resp = await c.post(
             "/mcp/",
             json={
diff --git a/hudi-agent-gateway/tests/test_spark_connect.py 
b/hudi-agent-gateway/tests/test_spark_connect.py
new file mode 100644
index 000000000000..c9df3aa1d52e
--- /dev/null
+++ b/hudi-agent-gateway/tests/test_spark_connect.py
@@ -0,0 +1,498 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""PySpark Connect lifecycle, bounded results, guardrails, and shared 
surfaces."""
+
+import asyncio
+import gc
+import json
+import threading
+import weakref
+from contextlib import contextmanager
+from unittest.mock import Mock, patch
+
+import httpx
+import pytest
+from fastmcp import Client
+from pydantic import ValidationError
+from pyspark.errors import PySparkException
+from pyspark.sql.types import IntegerType, StringType, StructField, StructType
+
+from hudi_agent_gateway.agent import build_system_prompt
+from hudi_agent_gateway.app import create_app
+from hudi_agent_gateway.config import GatewaySettings
+from hudi_agent_gateway.mcp_server import build_mcp
+from hudi_agent_gateway.tools import build_registry
+from hudi_agent_gateway.tools.guardrails import enforce_guardrails
+from hudi_agent_gateway.tools.lakehouse_client import QueryError, QueryResult, 
QueryTimeoutError
+from hudi_agent_gateway.tools.registry import ToolInputError
+from hudi_agent_gateway.tools.spark_connect_client import SparkConnectClient, 
_RequestSparkSession
+
+
[email protected]("closed", [True, False])
+def test_session_gc_does_not_close_an_already_stopped_client(closed):
+    session = _RequestSparkSession.__new__(_RequestSparkSession)
+    client = Mock(is_closed=closed)
+    session._client = client
+    # Spark's cached Catalog retains its session, so finalization happens 
during
+    # cyclic GC, potentially in an unrelated thread while locks are held.
+    session.catalog = Mock(_sparkSession=session)
+    ref = weakref.ref(session)
+    del session
+    gc.collect()
+    assert ref() is None
+    assert client.close.call_count == (0 if closed else 1)
+
+
+class DataFrame:
+    def __init__(self, session):
+        self.session = session
+        self.columns = ["id"]
+        self.schema = StructType(
+            [StructField("id", IntegerType(), False), StructField("region", 
StringType())]
+        )
+        self.limits = []
+        self.collected = False
+
+    def limit(self, rows):
+        self.limits.append(rows)
+        return self
+
+    def collect(self):
+        self.collected = True
+        self.session.started.set()
+        if self.session.release is not None:
+            self.session.release.wait(3)
+        if self.session.error:
+            raise self.session.error
+        return [(i,) for i in range(self.limits[-1])] if self.columns else []
+
+
+class Session:
+    def __init__(self):
+        self.catalog = self
+        self.catalog_name = None
+        self.schema_name = None
+        self.catalog_error = None
+        self.executed = []
+        self.closed = threading.Event()
+        self.cancelled = threading.Event()
+        self.started = threading.Event()
+        self.release = None
+        self.error = None
+        self.cancel_error = None
+        self.df = DataFrame(self)
+
+    def setCurrentCatalog(self, name):
+        self.catalog_name = name
+        if self.catalog_error:
+            raise self.catalog_error
+
+    def setCurrentDatabase(self, name):
+        self.schema_name = name
+
+    def sql(self, sql):
+        self.executed.append(sql)
+        return self.df
+
+    def interruptAll(self):
+        self.cancelled.set()
+        if self.cancel_error:
+            raise self.cancel_error
+        return ["operation"]
+
+    def stop(self):
+        self.closed.set()
+
+
+@contextmanager
+def pyspark_client(settings, *sessions):
+    client = SparkConnectClient(settings)
+    with patch(
+        "hudi_agent_gateway.tools.spark_connect_client._RequestSparkSession", 
side_effect=sessions
+    ) as connect:
+        try:
+            yield client, connect
+        finally:
+            for session in sessions:
+                if session.release is not None:
+                    session.release.set()
+            client.close()
+
+
+def spark_settings(settings):
+    return GatewaySettings(
+        **{
+            **settings.model_dump(),
+            "sql_backend": "spark-connect",
+            "spark_connect_uri": "sc://localhost:15002",
+        }
+    )
+
+
+async def test_pyspark_collect_is_bounded_and_session_closes(settings):
+    s = spark_settings(settings)
+    session = Session()
+    with pyspark_client(s, session) as (client, connect):
+        out = await client.execute("SELECT id FROM t LIMIT 3", timeout=1, 
max_rows=3)
+        assert out.rows == [[0], [1], [2]]
+        assert session.df.limits == [3]
+        assert session.closed.is_set()
+        assert connect.call_args.kwargs == {"connection": s.spark_connect_uri}
+        assert session.catalog_name == "spark_catalog" and session.schema_name 
== "default"
+
+
[email protected]("failure", ["query", "catalog"])
+async def test_pyspark_failures_close_session(settings, failure):
+    session = Session()
+    if failure == "query":
+        session.error = RuntimeError("gRPC call failed")
+    else:
+        session.catalog_error = PySparkException("Catalog not found")
+    with pyspark_client(spark_settings(settings), session) as (client, _):
+        with pytest.raises(QueryError):
+            await client.execute("SELECT 1", timeout=1, max_rows=1)
+        assert session.closed.is_set()
+
+
+async def test_pyspark_query_error_is_actionable_and_redacted(settings):
+    s = spark_settings(settings).model_copy(
+        update={"spark_connect_uri": 
"sc://localhost:15002/;token=secret%20token"}
+    )
+    session = Session()
+    session.error = PySparkException(
+        f"TABLE_NOT_FOUND at {s.spark_connect_uri}: secret token"
+    )
+    with pyspark_client(s, session) as (client, _):
+        with pytest.raises(QueryError, match="TABLE_NOT_FOUND") as caught:
+            await client.execute("SELECT * FROM absent", timeout=1, max_rows=1)
+        assert "secret" not in str(caught.value)
+
+
+async def test_pyspark_describe_uses_schema_without_collecting(settings):
+    session = Session()
+    with pyspark_client(spark_settings(settings), session) as (client, _):
+        out = await client.describe("SELECT * FROM t LIMIT 0", timeout=1, 
max_rows=2)
+        assert out.columns == ["column_name", "data_type", "is_nullable"]
+        assert out.rows == [["id", "int", "NO"], ["region", "string", "YES"]]
+        assert not session.df.collected
+        assert session.closed.is_set()
+
+
[email protected]("cancel_task", [False, True])
+async def 
test_timeout_and_cancellation_cleanup_without_blocking_loop(settings, 
cancel_task):
+    session = Session()
+    session.release = threading.Event()
+    with pyspark_client(spark_settings(settings), session) as (client, _):
+        task = asyncio.create_task(client.execute("SELECT 1", timeout=0.05, 
max_rows=1))
+        assert await asyncio.to_thread(session.started.wait, 1)
+        if cancel_task:
+            task.cancel()
+        with pytest.raises(asyncio.CancelledError if cancel_task else 
QueryTimeoutError):
+            await task
+        assert await asyncio.to_thread(session.cancelled.wait, 1)
+        assert not session.closed.is_set()  # Worker still owns the session.
+        session.release.set()
+        assert await asyncio.to_thread(session.closed.wait, 1)
+
+
+async def 
test_cancellation_is_isolated_and_failure_does_not_prevent_cleanup(settings):
+    slow, fast = Session(), Session()
+    slow.release = threading.Event()
+    slow.cancel_error = RuntimeError("interrupt RPC failed")
+    with pyspark_client(spark_settings(settings), slow, fast) as (client, 
connect):
+        task = asyncio.create_task(client.execute("SELECT 1", timeout=0.1, 
max_rows=1))
+        assert await asyncio.to_thread(slow.started.wait, 1)
+        assert (await client.execute("SELECT 2", timeout=1, max_rows=1)).rows 
== [[0]]
+        with pytest.raises(QueryTimeoutError):
+            await task
+        assert await asyncio.to_thread(slow.cancelled.wait, 1)
+        assert not fast.cancelled.is_set() and fast.closed.is_set()
+        assert connect.call_count == 2
+        slow.release.set()
+        assert await asyncio.to_thread(slow.closed.wait, 1)
+
+
+async def test_timed_out_workers_keep_capacity_slots(settings):
+    sessions = [Session() for _ in range(8)]
+    for session in sessions:
+        session.release = threading.Event()
+    with pyspark_client(spark_settings(settings), *sessions) as (client, _):
+        jobs = [client.execute("SELECT 1", timeout=0.1, max_rows=1) for _ in 
range(8)]
+        assert all(
+            isinstance(e, QueryTimeoutError)
+            for e in await asyncio.gather(*jobs, return_exceptions=True)
+        )
+        with pytest.raises(QueryError, match="capacity exhausted"):
+            await client.execute("SELECT 1", timeout=1, max_rows=1)
+        for session in sessions:
+            session.release.set()
+            assert await asyncio.to_thread(session.closed.wait, 1)
+
+
+async def test_ping_cached_and_closed_client_rejected(settings):
+    session = Session()
+    with pyspark_client(spark_settings(settings), session) as (client, _):
+        assert await client.ping()
+        assert await client.ping()
+        assert session.executed == ["SELECT 1"]
+        client.close()
+        with pytest.raises(QueryError, match="closed"):
+            await client.execute("SELECT 1", timeout=1, max_rows=1)
+
+
+async def test_ping_works_when_query_capacity_is_exhausted(settings):
+    sessions = [Session() for _ in range(9)]
+    for session in sessions[:8]:
+        session.release = threading.Event()
+    with pyspark_client(spark_settings(settings), *sessions) as (client, _):
+        tasks = [asyncio.create_task(client.execute("SELECT 1", timeout=5, 
max_rows=1))
+                 for _ in range(8)]
+        for session in sessions[:8]:
+            assert await asyncio.to_thread(session.started.wait, 1)
+        assert await client.ping(timeout=1)
+        for session in sessions[:8]:
+            session.release.set()
+        await asyncio.gather(*tasks)
+
+
+async def 
test_concurrent_pings_share_probe_even_if_a_waiter_is_cancelled(settings):
+    session = Session()
+    session.release = threading.Event()
+    with pyspark_client(spark_settings(settings), session) as (client, 
connect):
+        first = asyncio.create_task(client.ping())
+        assert await asyncio.to_thread(session.started.wait, 1)
+        second = asyncio.create_task(client.ping())
+        await asyncio.sleep(0)
+        first.cancel()
+        with pytest.raises(asyncio.CancelledError):
+            await first
+        assert not second.done()
+        session.release.set()
+        assert await second
+        assert await client.ping()
+        assert connect.call_count == 1
+
+
+async def test_close_interrupts_active_operations(settings):
+    session = Session()
+    session.release = threading.Event()
+    with pyspark_client(spark_settings(settings), session) as (client, _):
+        task = asyncio.create_task(client.execute("SELECT 1", timeout=5, 
max_rows=1))
+        assert await asyncio.to_thread(session.started.wait, 1)
+        client.close()
+        assert await asyncio.to_thread(session.cancelled.wait, 1)
+        session.release.set()
+        await task
+
+
[email protected](
+    "sql",
+    [
+        "INSERT INTO t VALUES (1)",
+        "UPDATE t SET a=1",
+        "DELETE FROM t",
+        "DROP TABLE t",
+        "WITH x AS (INSERT INTO t VALUES (1)) SELECT * FROM x",
+        "SELECT 1; DELETE FROM t",
+        "INSERT OVERWRITE DIRECTORY '/tmp/x' SELECT 1",
+        "SELECT * INTO t FROM u",
+        "CACHE TABLE t",
+        "SET spark.sql.shuffle.partitions=1",
+    ],
+)
+def test_spark_writes_rejected(sql):
+    with pytest.raises(ToolInputError):
+        enforce_guardrails(sql, 100, dialect="spark")
+
+
[email protected]("function", ["java_method", "ReFlEcT", "TRY_REFLECT"])
[email protected](
+    "query",
+    [
+        "SELECT {call}",
+        "WITH x AS (SELECT {call} AS value) SELECT value FROM x",
+        "SELECT value FROM (SELECT {call} AS value) x",
+        "SELECT builtin.{call}",
+        "SELECT 1 UNION ALL SELECT {call}",
+    ],
+)
+async def test_spark_reflection_rejected_before_client(settings, fake_trino, 
function, query):
+    call = f"{function}('java.lang.System', 'setProperty', 'k', 'v')"
+    sql = query.format(call=call)
+    with pytest.raises(ToolInputError, match="read-only: Spark function"):
+        enforce_guardrails(sql, 100, dialect="spark")
+    registry = build_registry(spark_settings(settings), fake_trino)
+    result = json.loads(await registry.get("query_lakehouse").handler(sql=sql))
+    assert "read-only: Spark function" in result["error"]
+    async with Client(build_mcp(registry)) as mcp:
+        result = await mcp.call_tool("query_lakehouse", {"sql": sql})
+        assert "read-only: Spark function" in 
json.loads(result.content[0].text)["error"]
+    assert not fake_trino.executed
+
+
+def test_spark_sql_quoting_and_functions():
+    out = enforce_guardrails(
+        "SELECT `weird col`, date_trunc('DAY', ts) FROM `t`", 100, 
dialect="spark"
+    )
+    assert "`weird col`" in out and "LIMIT 100" in out
+
+
+async def 
test_query_qualifies_format_named_schema_with_configured_catalog(settings, 
fake_trino):
+    s = spark_settings(settings).model_copy(update={"spark_connect_catalog": 
"custom_catalog"})
+    registry = build_registry(s, fake_trino)
+    await registry.get("query_lakehouse").handler(sql="SELECT * FROM 
text.orders")
+    assert "`custom_catalog`.text.orders" in fake_trino.executed[-1]
+
+
[email protected]("sql", [
+    "SELECT * FROM hudi_table_changes('s3a://other-bucket/tbl', 
'latest_state', 'earliest')",
+    "SELECT * FROM hudi_query_timeline(concat('s3a:', '//other-bucket/tbl'), 
'false')",
+    "SELECT * FROM hudi_filesystem_view('default.orders', '../other-table')",
+    "SELECT * FROM IDENTIFIER('text.`/etc/passwd`')",
+])
+async def test_spark_table_function_bypasses_rejected_before_client(settings, 
fake_trino, sql):
+    registry = build_registry(spark_settings(settings), fake_trino)
+    result = json.loads(await registry.get("query_lakehouse").handler(sql=sql))
+    assert "read-only:" in result["error"]
+    async with Client(build_mcp(registry)) as mcp:
+        result = await mcp.call_tool("query_lakehouse", {"sql": sql})
+        assert "read-only:" in json.loads(result.content[0].text)["error"]
+    assert not fake_trino.executed
+
+
[email protected](
+    "sql",
+    [
+        "SELECT 'java_method', 'reflect', 'try_reflect' AS function_names",
+        "SELECT `java_method`, `reflect`, `try_reflect` FROM t",
+        "SELECT 1 /* java_method('java.lang.System', 'setProperty', 'k', 'v') 
*/",
+    ],
+)
+def test_spark_reflection_names_without_calls_are_allowed(sql):
+    assert "LIMIT 100" in enforce_guardrails(sql, 100, dialect="spark")
+
+
+async def test_spark_tools_metadata_mcp_and_readonly(settings, fake_trino):
+    s = spark_settings(settings)
+    registry = build_registry(s, fake_trino)
+    fake_trino.result = QueryResult(
+        ["namespace", "tableName", "isTemporary"], [["default", "orders", 
False]]
+    )
+    out = json.loads(await registry.get("list_tables").handler())
+    assert out["columns"] == ["table_schema", "table_name", "table_type"]
+    assert out["rows"] == [["default", "orders", "TABLE"]]
+    assert fake_trino.executed[-1] == "SHOW TABLES IN 
`spark_catalog`.`default`"
+    fake_trino.result = QueryResult(
+        ["column_name", "data_type", "is_nullable"], [["id", "int32", "YES"]]
+    )
+    await registry.get("describe_table").handler(table="orders")
+    assert fake_trino.executed[-1] == "SELECT * FROM 
`spark_catalog`.`default`.`orders` LIMIT 0"
+    async with Client(build_mcp(registry)) as mcp:
+        assert {t.name for t in await mcp.list_tools()} == {
+            "list_tables",
+            "describe_table",
+            "query_lakehouse",
+        }
+        before = len(fake_trino.executed)
+        result = await mcp.call_tool("query_lakehouse", {"sql": "DELETE FROM 
orders"})
+        assert "read-only" in json.loads(result.content[0].text)["error"]
+        assert len(fake_trino.executed) == before
+        await mcp.call_tool("query_lakehouse", {"sql": "SELECT `id` FROM 
orders"})
+        assert "LIMIT 100" in fake_trino.executed[-1]
+    assert "Spark Connect" in build_system_prompt(s)
+    assert "spark_catalog" in build_system_prompt(s)
+
+
[email protected]("port, expected_port", [("", 15002), (":1", 1), 
(":65535", 65535)])
+async def test_spark_ready_info_and_shutdown(
+    settings, fake_trino, monkeypatch, port, expected_port
+):
+    s = GatewaySettings(
+        **{
+            **spark_settings(settings).model_dump(),
+            "spark_connect_uri": f"sc://localhost{port}",
+        }
+    )
+    monkeypatch.setattr("hudi_agent_gateway.app.build_client", lambda _: 
fake_trino)
+    app = create_app(s.model_copy(update={"mcp_enabled": False}))
+    async with (
+        app.router.lifespan_context(app),
+        httpx.AsyncClient(
+            transport=httpx.ASGITransport(app=app),
+            base_url="http://test";,
+        ) as http,
+    ):
+        info = (await http.get("/v1/info")).json()
+        assert info["sql_backend"] == "spark-connect"
+        assert info["catalog"] == "spark_catalog" and not info["trino_url"]
+        assert info["lakehouse_url"] == f"sc://localhost:{expected_port}"
+        fake_trino.fail_with("unreachable")
+        ready = await http.get("/ready")
+        assert ready.status_code == 503
+        assert "spark-connect" in ready.json()["checks"]
+        assert "trino" not in ready.json()["checks"]
+
+
[email protected](
+    "uri, endpoint",
+    [
+        ("sc://localhost", "sc://localhost:15002"),
+        ("sc://localhost/;use_ssl=true;token=secret%20token", 
"sc://localhost:15002"),
+        ("sc://[::1]:15002/;token=secret", "sc://[::1]:15002"),
+    ],
+)
+async def test_connect_uri_parameters_are_accepted_and_info_is_redacted(
+    settings, fake_trino, monkeypatch, uri, endpoint
+):
+    from pyspark.sql.connect.client import DefaultChannelBuilder
+
+    s = GatewaySettings(**{**spark_settings(settings).model_dump(), 
"spark_connect_uri": uri})
+    assert DefaultChannelBuilder(uri).endpoint == 
endpoint.removeprefix("sc://")
+    monkeypatch.setattr("hudi_agent_gateway.app.build_client", lambda _: 
fake_trino)
+    app = create_app(s.model_copy(update={"mcp_enabled": False}))
+    async with (
+        app.router.lifespan_context(app),
+        httpx.AsyncClient(transport=httpx.ASGITransport(app=app), 
base_url="http://test";) as http,
+    ):
+        response = await http.get("/v1/info")
+        assert response.json()["lakehouse_url"] == endpoint
+        assert "secret" not in response.text
+
+
[email protected](
+    "uri",
+    [
+        "spark://localhost:15002?api=connect",
+        "http://localhost:15002";,
+        "sc://localhost?api=connect",
+        "sc://localhost:not-a-port",
+        "sc://localhost:65536",
+        "sc://localhost:-1",
+        "sc://localhost:0",
+        "sc://[::1]:not-a-port",
+        "sc://[::1]:65536",
+        "sc:///",
+        "sc://user:secret@localhost:15002",
+        "sc://localhost/database",
+        "sc://localhost/;token",
+        "sc://localhost/;token=a;",
+        "sc://localhost#fragment",
+    ],
+)
+def test_bad_connect_uri_fails_fast(uri):
+    with pytest.raises(ValidationError, match="GATEWAY_SPARK_CONNECT_URI"):
+        GatewaySettings(sql_backend="spark-connect", spark_connect_uri=uri)
diff --git a/hudi-lakehouse/README.md b/hudi-lakehouse/README.md
index 29bcca8f3b6c..d362eec1f2ca 100644
--- a/hudi-lakehouse/README.md
+++ b/hudi-lakehouse/README.md
@@ -83,7 +83,7 @@ catalog, and query engines are independent. This directory 
keeps the
    |---|---|---|
    | `hudi-trino` | Trino (server 472) with the Hudi connector built from this 
repository | your Hive Metastore or AWS Glue; your S3/GCS |
    | `hudi-spark-connect` | optional Spark Connect 4.1.3 with Hudi 1.2.0; one 
shared local-mode driver | your Hive Metastore and S3-compatible storage |
-   | `hudi-agent-gateway` | the [Hudi AI gateway](../hudi-agent-gateway): 
agent chat API (sessions + SSE), MCP server, and chat UI over guarded lakehouse 
tools | your `hudi-trino`; your LLM (see below) |
+   | `hudi-agent-gateway` | the [Hudi AI gateway](../hudi-agent-gateway): 
agent chat API (sessions + SSE), MCP server, and chat UI over guarded lakehouse 
tools | your Trino or Spark Connect endpoint; your LLM (see below) |
    | `vllm` | optional: vLLM serving one open-weight model (default 
`Qwen/Qwen3-8B`, ungated) behind an OpenAI-compatible API; GPU required | — 
(the gateway points at it) |
 
    **LLM out-of-box experience**: the gateway defaults to **Ollama**
@@ -170,7 +170,12 @@ TLS; use it on a trusted network. See the [chart 
installation guide](charts/hudi
 for storage, credentials, and resource settings, and the [ADBC client 
guide](charts/hudi-spark-connect/README.md#connect-and-validate-with-adbc)
 for connection setup and CRUD/restart validation.
 
-## BI clients on Spark Connect
+## Gateway and BI clients on Spark Connect
+
+The gateway selects the official PySpark Connect client with
+`sqlBackend: spark-connect`; its `list_tables`, `describe_table`, and
+`query_lakehouse` tools remain read-only. See the
+[gateway setup](charts/hudi-spark-connect/README.md#agent-gateway) for the 
Helm overlay.
 
 See the [BI client 
notes](charts/hudi-spark-connect/README.md#spark-connect-client-tutorials)
 for Superset and Zeppelin connection requirements and ADBC limitations.
diff --git a/hudi-lakehouse/charts/hudi-agent-gateway/templates/NOTES.txt 
b/hudi-lakehouse/charts/hudi-agent-gateway/templates/NOTES.txt
index 1670610e5ec4..01578a317bd5 100644
--- a/hudi-lakehouse/charts/hudi-agent-gateway/templates/NOTES.txt
+++ b/hudi-lakehouse/charts/hudi-agent-gateway/templates/NOTES.txt
@@ -18,7 +18,12 @@ limitations under the License.
 hudi-agent-gateway {{ .Chart.Version }} deployed.
 
   provider : {{ .Values.gateway.provider }} ({{ .Values.gateway.model }})
+  backend  : {{ .Values.sqlBackend }}
+{{- if eq .Values.sqlBackend "trino" }}
   trino    : {{ .Values.trino.host }}:{{ .Values.trino.port }} (catalog {{ 
.Values.trino.catalog }})
+{{- else }}
+  catalog  : {{ .Values.sparkConnect.catalog }} / {{ 
.Values.sparkConnect.schema }}
+{{- end }}
   mcp      : {{ if .Values.mcpEnabled }}enabled at /mcp{{ else }}disabled{{ 
end }}
 
 Try it:
@@ -42,5 +47,5 @@ loopback, restart it reachable from the cluster:
 {{- end }}
 
 Readiness (kubectl -n {{ .Release.Namespace }} get pods) stays NotReady until
-both Trino and the LLM endpoint are reachable -- `curl localhost:8000/ready`
+both {{ .Values.sqlBackend }} and the LLM endpoint are reachable -- `curl 
localhost:8000/ready`
 shows per-dependency status.
diff --git a/hudi-lakehouse/charts/hudi-agent-gateway/templates/deployment.yaml 
b/hudi-lakehouse/charts/hudi-agent-gateway/templates/deployment.yaml
index 2258d2f5466e..1e12acf4e901 100644
--- a/hudi-lakehouse/charts/hudi-agent-gateway/templates/deployment.yaml
+++ b/hudi-lakehouse/charts/hudi-agent-gateway/templates/deployment.yaml
@@ -14,6 +14,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 {{- $g := .Values.gateway }}
+{{- if not (has .Values.sqlBackend (list "trino" "spark-connect")) }}
+{{- fail "sqlBackend must be trino or spark-connect" }}
+{{- end }}
 apiVersion: apps/v1
 kind: Deployment
 metadata:
@@ -57,6 +60,8 @@ spec:
               value: {{ $g.model | quote }}
             - name: GATEWAY_LLM_TIMEOUT_SECONDS
               value: {{ $g.llmTimeoutSeconds | quote }}
+            - name: GATEWAY_CHAT_TIMEOUT_SECONDS
+              value: {{ $g.chatTimeoutSeconds | quote }}
             {{- with $g.ollama.baseUrl }}
             - name: GATEWAY_OLLAMA_BASE_URL
               value: {{ . | quote }}
@@ -69,6 +74,23 @@ spec:
             - name: GATEWAY_SYSTEM_PROMPT_EXTRA
               value: {{ . | quote }}
             {{- end }}
+            - name: GATEWAY_SQL_BACKEND
+              value: {{ .Values.sqlBackend | quote }}
+            {{- if eq .Values.sqlBackend "spark-connect" }}
+            - name: GATEWAY_SPARK_CONNECT_URI
+              {{- if .Values.sparkConnect.existingSecret }}
+              valueFrom:
+                secretKeyRef:
+                  name: {{ .Values.sparkConnect.existingSecret | quote }}
+                  key: {{ .Values.sparkConnect.uriSecretKey | quote }}
+              {{- else }}
+              value: {{ .Values.sparkConnect.uri | quote }}
+              {{- end }}
+            - name: GATEWAY_SPARK_CONNECT_CATALOG
+              value: {{ .Values.sparkConnect.catalog | quote }}
+            - name: GATEWAY_SPARK_CONNECT_SCHEMA
+              value: {{ .Values.sparkConnect.schema | quote }}
+            {{- end }}
             - name: GATEWAY_TRINO_HOST
               value: {{ .Values.trino.host | quote }}
             - name: GATEWAY_TRINO_PORT
@@ -109,7 +131,7 @@ spec:
               path: /ready
               port: http
             periodSeconds: 10
-            # /ready's own dependency checks budget up to ~8s (5s trino ping +
+            # /ready's own dependency checks budget up to ~8s (5s lakehouse 
ping +
             # 3s LLM probe) on a cache miss; the k8s default of 1s would flap.
             timeoutSeconds: 9
             failureThreshold: 3
diff --git a/hudi-lakehouse/charts/hudi-agent-gateway/values-local-dev.yaml 
b/hudi-lakehouse/charts/hudi-agent-gateway/values-local-dev.yaml
index 143a24e4403c..a24e8c7eccf2 100644
--- a/hudi-lakehouse/charts/hudi-agent-gateway/values-local-dev.yaml
+++ b/hudi-lakehouse/charts/hudi-agent-gateway/values-local-dev.yaml
@@ -24,7 +24,6 @@ fullnameOverride: hudi-agent-gateway
 gateway:
   provider: ollama
   # A small tool-capable model that fits a laptop; `ollama pull qwen3:4b`.
-  # (1.7b also works but makes more reasoning mistakes on SQL tasks.)
   model: qwen3:4b
   ollama:
     baseUrl: http://host.minikube.internal:11434
diff --git a/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py 
b/hudi-lakehouse/charts/hudi-agent-gateway/values-spark-connect.yaml
similarity index 52%
copy from hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
copy to hudi-lakehouse/charts/hudi-agent-gateway/values-spark-connect.yaml
index 9e4db82cdc7b..ad42c0a59c38 100644
--- a/hudi-agent-gateway/src/hudi_agent_gateway/tools/__init__.py
+++ b/hudi-lakehouse/charts/hudi-agent-gateway/values-spark-connect.yaml
@@ -14,24 +14,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""Tool assembly: the single place where tool modules get registered.
-
-Adding a new tool module = write the module with a ``register(registry, ...)``
-function, then call it here.
-"""
-
-from __future__ import annotations
-
-from hudi_agent_gateway.config import GatewaySettings
-from hudi_agent_gateway.tools import trino_tools
-from hudi_agent_gateway.tools.registry import ToolRegistry
-from hudi_agent_gateway.tools.trino_client import TrinoClient
-
-
-def build_registry(
-    settings: GatewaySettings, trino_client: TrinoClient | None = None
-) -> ToolRegistry:
-    registry = ToolRegistry()
-    client = trino_client or TrinoClient(settings)
-    trino_tools.register(registry, client, settings)
-    return registry
+# Overlay after values-local-dev.yaml to use the shared Connect endpoint.
+sqlBackend: spark-connect
+sparkConnect:
+  uri: "sc://hudi-spark-connect.hudi-lakehouse.svc:15002"
+  catalog: spark_catalog
+  schema: default
diff --git a/hudi-lakehouse/charts/hudi-agent-gateway/values.yaml 
b/hudi-lakehouse/charts/hudi-agent-gateway/values.yaml
index e8880ba9ab60..d4d2f3a73699 100644
--- a/hudi-lakehouse/charts/hudi-agent-gateway/values.yaml
+++ b/hudi-lakehouse/charts/hudi-agent-gateway/values.yaml
@@ -54,6 +54,19 @@ gateway:
     # Endpoint for provider=openai-compatible (vLLM, Together, Fireworks, ...).
     baseUrl: ""
   llmTimeoutSeconds: 120
+  # Total wall-clock limit for a chat turn, including all model/tool calls.
+  chatTimeoutSeconds: 300
+
+# trino | spark-connect
+sqlBackend: trino
+
+sparkConnect:
+  uri: "sc://hudi-spark-connect.hudi-lakehouse.svc:15002"
+  catalog: spark_catalog
+  schema: default
+  # Optional Secret key containing GATEWAY_SPARK_CONNECT_URI (e.g. auth 
tokens).
+  existingSecret: ""
+  uriSecretKey: GATEWAY_SPARK_CONNECT_URI
 
 trino:
   host: hudi-trino.hudi-lakehouse.svc
diff --git a/hudi-lakehouse/charts/hudi-spark-connect/README.md 
b/hudi-lakehouse/charts/hudi-spark-connect/README.md
index ba949104c08e..fd7c7d45c207 100644
--- a/hudi-lakehouse/charts/hudi-spark-connect/README.md
+++ b/hudi-lakehouse/charts/hudi-spark-connect/README.md
@@ -284,11 +284,37 @@ An existing session did not see an externally added 
column until
 recreation passed. Further end-to-end validation is tracked in
 [#20233](https://github.com/apache/hudi/issues/20233).
 
+## Agent gateway
+
+See the [gateway setup and 
limits](../../../hudi-agent-gateway/README.md#spark-connect-backend).
+The Agent and MCP interfaces share the same three read-only tools:
+`list_tables`, `describe_table`, and `query_lakehouse`.
+
+Build the gateway image, which includes the official PySpark Connect client:
+
+```bash
+docker build -t hudi-lakehouse-agent-gateway:0.1.0 hudi-agent-gateway
+```
+
+Load or push that image for your cluster, then install the gateway from the
+repository root:
+
+```bash
+helm upgrade --install hudi-agent-gateway 
hudi-lakehouse/charts/hudi-agent-gateway \
+  -n hudi-lakehouse \
+  -f hudi-lakehouse/charts/hudi-agent-gateway/values-local-dev.yaml \
+  -f hudi-lakehouse/charts/hudi-agent-gateway/values-spark-connect.yaml
+```
+
+This retains the local-dev Ollama configuration. `/ready` checks Spark with
+`SELECT 1` and checks the configured LLM separately. MCP tool calls themselves
+do not require an LLM. Override the provider as described in the gateway 
README.
+
 <a id="spark-connect-client-tutorials"></a>
 
 ## BI clients
 
-Gateway integration and BI adapters/examples for Superset and other tools are 
tracked
+BI adapters/examples for Superset and other tools are tracked
 in [#20232](https://github.com/apache/hudi/issues/20232) and will be delivered 
in follow-up PRs.
 The initial SQLAlchemy adapter is planned under `hudi-lakehouse`; upstreaming 
can
 be evaluated separately after the integration stabilizes.
diff --git a/hudi-lakehouse/charts/hudi-spark-connect/templates/configmap.yaml 
b/hudi-lakehouse/charts/hudi-spark-connect/templates/configmap.yaml
index 5fa146214ca8..11298a929992 100644
--- a/hudi-lakehouse/charts/hudi-spark-connect/templates/configmap.yaml
+++ b/hudi-lakehouse/charts/hudi-spark-connect/templates/configmap.yaml
@@ -22,6 +22,7 @@
   "spark.connect.grpc.binding.address" "0.0.0.0"
   "spark.connect.grpc.binding.port" "15002"
   "spark.sql.catalogImplementation" "hive"
+  "spark.sql.runSQLOnFiles" "false"
   "spark.hadoop.hive.metastore.uris" $uri
   "spark.sql.warehouse.dir" $warehouse
   "spark.sql.extensions" 
"org.apache.spark.sql.hudi.HoodieSparkSessionExtension"
diff --git a/hudi-lakehouse/tests/test_gateway_chart.py 
b/hudi-lakehouse/tests/test_gateway_chart.py
new file mode 100644
index 000000000000..5d9a77c59cf2
--- /dev/null
+++ b/hudi-lakehouse/tests/test_gateway_chart.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Gateway backend and Secret chart contracts."""
+
+import pathlib
+import subprocess
+import unittest
+
+import yaml
+
+CHART = pathlib.Path(__file__).resolve().parents[1] / 
"charts/hudi-agent-gateway"
+
+
+def render(*args):
+    result = subprocess.run(["helm", "template", "test", str(CHART), *args],
+                            capture_output=True, text=True, check=True)
+    deployment = next(d for d in yaml.safe_load_all(result.stdout) if d and 
d["kind"] == "Deployment")
+    container = deployment["spec"]["template"]["spec"]["containers"][0]
+    return {e["name"]: e for e in container["env"]}
+
+
+class GatewayChartTest(unittest.TestCase):
+    def test_default_trino(self):
+        env = render()
+        self.assertEqual(env["GATEWAY_SQL_BACKEND"]["value"], "trino")
+        self.assertNotIn("GATEWAY_SPARK_CONNECT_URI", env)
+
+    def test_connect_overlay(self):
+        env = render("-f", str(CHART / "values-spark-connect.yaml"))
+        self.assertEqual(env["GATEWAY_SQL_BACKEND"]["value"], "spark-connect")
+        self.assertEqual(env["GATEWAY_SPARK_CONNECT_URI"]["value"],
+                         "sc://hudi-spark-connect.hudi-lakehouse.svc:15002")
+        self.assertEqual(env["GATEWAY_SPARK_CONNECT_CATALOG"]["value"], 
"spark_catalog")
+        self.assertNotIn("GATEWAY_SPARK_CONNECT_DRIVER", env)
+
+    def test_connect_uri_secret(self):
+        env = render("--set", "sqlBackend=spark-connect", "--set", 
"sparkConnect.existingSecret=connect-auth",
+                     "--set", "sparkConnect.uriSecretKey=uri")
+        self.assertNotIn("value", env["GATEWAY_SPARK_CONNECT_URI"])
+        
self.assertEqual(env["GATEWAY_SPARK_CONNECT_URI"]["valueFrom"]["secretKeyRef"],
+                         {"name": "connect-auth", "key": "uri"})
+
+    def test_invalid_backend_rejected(self):
+        with self.assertRaises(subprocess.CalledProcessError):
+            render("--set", "sqlBackend=typo")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/hudi-lakehouse/tests/test_spark_connect_chart.py 
b/hudi-lakehouse/tests/test_spark_connect_chart.py
index 862586968422..b309aeb8206f 100644
--- a/hudi-lakehouse/tests/test_spark_connect_chart.py
+++ b/hudi-lakehouse/tests/test_spark_connect_chart.py
@@ -54,6 +54,7 @@ class ConnectChartTest(unittest.TestCase):
         self.assertIn("spark.connect.grpc.arrow.maxBatchSize 1m", config)
         self.assertIn("spark.hadoop.hive.metastore.uris 
thrift://hive-metastore.", config)
         self.assertIn("spark.sql.warehouse.dir s3a://warehouse/connect", 
config)
+        self.assertIn("spark.sql.runSQLOnFiles false", config)
         self.assertIn("org.apache.spark.sql.hudi.catalog.HoodieCatalog", 
config)
         self.assertIn("HoodieSparkSessionExtension", config)
         self.assertEqual(docs["Deployment"]["spec"]["replicas"], 1)

Reply via email to