This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 668af43da0 [#12129] feat(mcp-server): add read tools for views (#12130)
668af43da0 is described below
commit 668af43da09c23d2f8694f13422a803cdd6f215a
Author: Bharath Krishna <[email protected]>
AuthorDate: Tue Aug 4 01:04:32 2026 -0700
[#12129] feat(mcp-server): add read tools for views (#12130)
### What changes were proposed in this pull request?
Add two read-only MCP tools for views:
- `list_of_views(catalog, schema)` — lists the views in a schema.
- `load_view(catalog, schema, view)` — returns the full metadata of a
view
(columns, SQL representations, default catalog/schema, properties,
audit).
They follow the existing metadata-type pattern (interface → plain REST
client →
FastMCP tool → mock + unit tests). All user-supplied path segments are
URL-encoded, and authorization is enforced by Gravitino, so no filtering
logic is
added in the MCP layer.
### Why are the changes needed?
Views round out read coverage for relational catalogs. Unlike partitions
(Hive-only), views are supported by both the Hive and the
`lakehouse-iceberg`
catalogs (`IcebergCatalog.asViewCatalog()`), so this is useful for
Iceberg
deployments too.
Fix: #12129
### Does this PR introduce _any_ user-facing change?
Yes — two new MCP tools (`list_of_views`, `load_view`), tagged `view`.
### How was this patch tested?
Added unit tests for the tools (`test_view.py`) and for URL-encoding of
the view
endpoints (`test_url_encoding.py`). Full unit suite passes
Local dev testing results here:
https://github.com/apache/gravitino/issues/12129#issuecomment-5055509650
---
docs/gravitino-mcp-server.md | 2 +
.../mcp_server/client/gravitino_operation.py | 11 ++
.../client/plain/plain_rest_client_operation.py | 9 ++
.../plain/plain_rest_client_view_operation.py | 51 ++++++++
mcp-server/mcp_server/client/view_operation.py | 55 ++++++++
mcp-server/mcp_server/tools/__init__.py | 2 +
mcp-server/mcp_server/tools/view.py | 143 +++++++++++++++++++++
mcp-server/tests/unit/client/test_url_encoding.py | 21 +++
mcp-server/tests/unit/tools/mock_operation.py | 14 ++
mcp-server/tests/unit/tools/test_view.py | 68 ++++++++++
10 files changed, 376 insertions(+)
diff --git a/docs/gravitino-mcp-server.md b/docs/gravitino-mcp-server.md
index 605392c091..ee97e68577 100644
--- a/docs/gravitino-mcp-server.md
+++ b/docs/gravitino-mcp-server.md
@@ -126,6 +126,8 @@ Gravitino MCP server supports the following tools, and you
could export tool by
| `get_policy_for_metadata` | Get a policy associated with a
specific metadata item. | `policy` |
| `list_of_partitions` | Retrieve partitions for a table. Only
for catalogs with a partition API. | `partition` |
| `get_partition` | Retrieve a partition's metadata. Only
for catalogs with a partition API. | `partition` |
+| `list_of_views` | Retrieve a list of views for a schema.
Only for catalogs supporting views. | `view` |
+| `load_view` | Retrieve a view's metadata. Only for
catalogs supporting views. | `view` |
## Configuration
diff --git a/mcp-server/mcp_server/client/gravitino_operation.py
b/mcp-server/mcp_server/client/gravitino_operation.py
index c8ff7fa719..0943ab0120 100644
--- a/mcp-server/mcp_server/client/gravitino_operation.py
+++ b/mcp-server/mcp_server/client/gravitino_operation.py
@@ -28,6 +28,7 @@ from mcp_server.client.statistic_operation import
StatisticOperation
from mcp_server.client.table_operation import TableOperation
from mcp_server.client.tag_operation import TagOperation
from mcp_server.client.topic_operation import TopicOperation
+from mcp_server.client.view_operation import ViewOperation
class GravitinoOperation(ABC):
@@ -143,3 +144,13 @@ class GravitinoOperation(ABC):
PartitionOperation: Interface for performing partition-level
operations
"""
pass
+
+ @abstractmethod
+ def as_view_operation(self) -> ViewOperation:
+ """
+ Access the view operation interface of this Gravitino operation.
+
+ Returns:
+ ViewOperation: Interface for performing view-level operations
+ """
+ pass
diff --git a/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
b/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
index a9e09bf2de..ff53b0c424 100644
--- a/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
+++ b/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
@@ -60,6 +60,9 @@ from mcp_server.client.plain.plain_rest_client_tag_operation
import (
from mcp_server.client.plain.plain_rest_client_topic_operation import (
PlainRESTClientTopicOperation,
)
+from mcp_server.client.plain.plain_rest_client_view_operation import (
+ PlainRESTClientViewOperation,
+)
from mcp_server.client.topic_operation import TopicOperation
@@ -115,6 +118,9 @@ class PlainRESTClientOperation(GravitinoOperation):
self._partition_operation = PlainRESTClientPartitionOperation(
metalake_name, _rest_client
)
+ self._view_operation = PlainRESTClientViewOperation(
+ metalake_name, _rest_client
+ )
async def close(self) -> None:
"""Close the shared httpx client and release its connection pool."""
@@ -152,3 +158,6 @@ class PlainRESTClientOperation(GravitinoOperation):
def as_partition_operation(self):
return self._partition_operation
+
+ def as_view_operation(self):
+ return self._view_operation
diff --git
a/mcp-server/mcp_server/client/plain/plain_rest_client_view_operation.py
b/mcp-server/mcp_server/client/plain/plain_rest_client_view_operation.py
new file mode 100644
index 0000000000..94f71eb0b7
--- /dev/null
+++ b/mcp-server/mcp_server/client/plain/plain_rest_client_view_operation.py
@@ -0,0 +1,51 @@
+# 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.
+
+from mcp_server.client.plain.utils import (
+ encode_path_segment,
+ extract_content_from_response,
+)
+from mcp_server.client.view_operation import ViewOperation
+
+
+class PlainRESTClientViewOperation(ViewOperation):
+ """
+ Implementation of ViewOperation using a plain REST client.
+ """
+
+ def __init__(self, metalake_name: str, rest_client):
+ self.metalake_name = metalake_name
+ self.rest_client = rest_client
+
+ async def list_of_views(self, catalog_name: str, schema_name: str) -> str:
+ response = await self.rest_client.get(
+ f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
+ f"/catalogs/{encode_path_segment(catalog_name)}"
+ f"/schemas/{encode_path_segment(schema_name)}/views"
+ )
+ return extract_content_from_response(response, "identifiers", [])
+
+ async def load_view(
+ self, catalog_name: str, schema_name: str, view_name: str
+ ) -> str:
+ response = await self.rest_client.get(
+ f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
+ f"/catalogs/{encode_path_segment(catalog_name)}"
+ f"/schemas/{encode_path_segment(schema_name)}"
+ f"/views/{encode_path_segment(view_name)}"
+ )
+ return extract_content_from_response(response, "view", {})
diff --git a/mcp-server/mcp_server/client/view_operation.py
b/mcp-server/mcp_server/client/view_operation.py
new file mode 100644
index 0000000000..ba144183e4
--- /dev/null
+++ b/mcp-server/mcp_server/client/view_operation.py
@@ -0,0 +1,55 @@
+# 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.
+
+from abc import ABC, abstractmethod
+
+
+class ViewOperation(ABC):
+ """
+ Abstract base class for Gravitino view operations.
+ """
+
+ @abstractmethod
+ async def list_of_views(self, catalog_name: str, schema_name: str) -> str:
+ """
+ Retrieve the list of views within a specified catalog and schema.
+
+ Args:
+ catalog_name: Name of the catalog
+ schema_name: Name of the schema
+
+ Returns:
+ str: JSON-formatted string containing view identifiers
+ """
+ pass
+
+ @abstractmethod
+ async def load_view(
+ self, catalog_name: str, schema_name: str, view_name: str
+ ) -> str:
+ """
+ Load detailed information of a specific view.
+
+ Args:
+ catalog_name: Name of the catalog
+ schema_name: Name of the schema
+ view_name: Name of the view
+
+ Returns:
+ str: JSON-formatted string containing full view metadata
+ """
+ pass
diff --git a/mcp-server/mcp_server/tools/__init__.py
b/mcp-server/mcp_server/tools/__init__.py
index c44726b038..1bbf302271 100644
--- a/mcp-server/mcp_server/tools/__init__.py
+++ b/mcp-server/mcp_server/tools/__init__.py
@@ -29,6 +29,7 @@ from mcp_server.tools.statistic import load_statistic_tools
from mcp_server.tools.table import load_table_tools
from mcp_server.tools.tag import load_tag_tool
from mcp_server.tools.topic import load_topic_tools
+from mcp_server.tools.view import load_view_tools
def load_tools(mcp: FastMCP):
@@ -44,3 +45,4 @@ def load_tools(mcp: FastMCP):
load_statistic_tools(mcp)
load_policy_tools(mcp)
load_partition_tools(mcp)
+ load_view_tools(mcp)
diff --git a/mcp-server/mcp_server/tools/view.py
b/mcp-server/mcp_server/tools/view.py
new file mode 100644
index 0000000000..ae295a931f
--- /dev/null
+++ b/mcp-server/mcp_server/tools/view.py
@@ -0,0 +1,143 @@
+# 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.
+
+from fastmcp import Context, FastMCP
+
+
+def load_view_tools(mcp: FastMCP):
+ @mcp.tool(tags={"view"})
+ async def list_of_views(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ ):
+ """
+ Retrieve a list of views within a specific catalog and schema.
+
+ Views are supported by relational catalogs such as Hive and
+ lakehouse-iceberg.
+
+ Parameters:
+ ctx (Context): The request context object containing lifespan
context
+ and connector information.
+ catalog_name (str): The name of the catalog to filter views by.
+ schema_name (str): The name of the schema to filter views by.
+
+ Returns:
+ str: A JSON string representing an array of view objects with the
+ following structure:
+ - namespace: The hierarchical namespace of the view
+ ([metalake, catalog, schema]).
+ - name: The name of the view.
+
+ Example Return Value:
+ [
+ {
+ "namespace": ["test", "iceberg_catalog", "default"],
+ "name": "daily_summary"
+ }
+ ]
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_view_operation().list_of_views(
+ catalog_name, schema_name
+ )
+
+ @mcp.tool(tags={"view"})
+ async def load_view(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ view_name: str,
+ ):
+ """
+ Load detailed information of a specific view.
+
+ Views are supported by relational catalogs such as Hive and
+ lakehouse-iceberg.
+
+ Parameters:
+ ctx (Context): The request context object containing lifespan
context
+ and connector information.
+ catalog_name (str): The name of the catalog containing the view.
+ schema_name (str): The name of the schema containing the view.
+ view_name (str): The name of the view to load.
+
+ Returns:
+ str: A JSON string containing full view metadata with the following
+ structure:
+ {
+ "name": "view-name", # View name
+ "comment": "description", # Human-readable
description
+ "columns": [ # Output column definitions
+ {
+ "name": "column-name",
+ "type": "data-type",
+ "comment": "column-description",
+ "nullable": true
+ }
+ ],
+ "representations": [ # Engine-specific
definitions
+ {
+ "type": "sql",
+ "dialect": "spark",
+ "sql": "SELECT ..."
+ }
+ ],
+ "defaultCatalog": "catalog-name", # Default catalog for name
+ # resolution (may be null)
+ "defaultSchema": "schema-name", # Default schema for name
+ # resolution (may be null)
+ "properties": {"key": "value"}, # View properties
+ "audit": {
+ "creator": "creator-name",
+ "createTime": "ISO-8601-timestamp"
+ }
+ }
+
+ Example Return Value:
+ {
+ "name": "daily_summary",
+ "comment": "Daily aggregated metrics",
+ "columns": [
+ {
+ "name": "dt",
+ "type": "date",
+ "comment": "partition date",
+ "nullable": true
+ }
+ ],
+ "representations": [
+ {
+ "type": "sql",
+ "dialect": "spark",
+ "sql": "SELECT dt FROM events GROUP BY dt"
+ }
+ ],
+ "defaultCatalog": null,
+ "defaultSchema": "default",
+ "properties": {},
+ "audit": {
+ "creator": "anonymous",
+ "createTime": "2025-08-03T11:36:04.856145Z"
+ }
+ }
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_view_operation().load_view(
+ catalog_name, schema_name, view_name
+ )
diff --git a/mcp-server/tests/unit/client/test_url_encoding.py
b/mcp-server/tests/unit/client/test_url_encoding.py
index 75fc38c008..3e1c9018da 100644
--- a/mcp-server/tests/unit/client/test_url_encoding.py
+++ b/mcp-server/tests/unit/client/test_url_encoding.py
@@ -55,6 +55,9 @@ from mcp_server.client.plain.plain_rest_client_tag_operation
import (
from mcp_server.client.plain.plain_rest_client_topic_operation import (
PlainRESTClientTopicOperation,
)
+from mcp_server.client.plain.plain_rest_client_view_operation import (
+ PlainRESTClientViewOperation,
+)
def _make_mock_client(response_json: dict):
@@ -548,3 +551,21 @@ class TestPartitionOperationUrlEncoding(unittest.TestCase):
url = _called_url(client.get)
self.assertIn(_ENCODED_QUERY_INJECTION, url)
self.assertNotIn("?admin=true", url)
+
+
+class TestViewOperationUrlEncoding(unittest.TestCase):
+ def test_list_of_views_encodes_schema_name(self):
+ client = _make_mock_client({"identifiers": []})
+ op = PlainRESTClientViewOperation(METALAKE, client)
+ asyncio.run(op.list_of_views("catalog", _PATH_TRAVERSAL))
+ url = _called_url(client.get)
+ self.assertIn(_ENCODED_PATH_TRAVERSAL, url)
+ self.assertNotIn("../../", url)
+
+ def test_load_view_encodes_view_name(self):
+ client = _make_mock_client({"view": {}})
+ op = PlainRESTClientViewOperation(METALAKE, client)
+ asyncio.run(op.load_view("catalog", "schema", _QUERY_INJECTION))
+ url = _called_url(client.get)
+ self.assertIn(_ENCODED_QUERY_INJECTION, url)
+ self.assertNotIn("?admin=true", url)
diff --git a/mcp-server/tests/unit/tools/mock_operation.py
b/mcp-server/tests/unit/tools/mock_operation.py
index 2abcd6fe57..3816bf56d5 100644
--- a/mcp-server/tests/unit/tools/mock_operation.py
+++ b/mcp-server/tests/unit/tools/mock_operation.py
@@ -29,6 +29,7 @@ from mcp_server.client.fileset_operation import
FilesetOperation
from mcp_server.client.job_operation import JobOperation
from mcp_server.client.partition_operation import PartitionOperation
from mcp_server.client.statistic_operation import StatisticOperation
+from mcp_server.client.view_operation import ViewOperation
class MockOperation(GravitinoOperation):
@@ -68,6 +69,9 @@ class MockOperation(GravitinoOperation):
def as_partition_operation(self) -> PartitionOperation:
return MockPartitionOperation()
+ def as_view_operation(self) -> ViewOperation:
+ return MockViewOperation()
+
class MockCatalogOperation(CatalogOperation):
async def get_list_of_catalogs(self) -> str:
@@ -470,3 +474,13 @@ class MockPartitionOperation(PartitionOperation):
f"mock_partition: {catalog_name}, {schema_name}, {table_name}, "
f"{partition_name}"
)
+
+
+class MockViewOperation(ViewOperation):
+ async def list_of_views(self, catalog_name: str, schema_name: str) -> str:
+ return f"mock_views: {catalog_name}, {schema_name}"
+
+ async def load_view(
+ self, catalog_name: str, schema_name: str, view_name: str
+ ) -> str:
+ return f"mock_view: {catalog_name}, {schema_name}, {view_name}"
diff --git a/mcp-server/tests/unit/tools/test_view.py
b/mcp-server/tests/unit/tools/test_view.py
new file mode 100644
index 0000000000..3136003ab3
--- /dev/null
+++ b/mcp-server/tests/unit/tools/test_view.py
@@ -0,0 +1,68 @@
+# 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.
+
+import asyncio
+import unittest
+
+from fastmcp import Client
+
+from mcp_server.client.factory import RESTClientFactory
+from mcp_server.core import Setting
+from mcp_server.server import GravitinoMCPServer
+from tests.unit.tools import MockOperation
+
+
+class TestViewTool(unittest.TestCase):
+ def setUp(self):
+ RESTClientFactory.set_rest_client(MockOperation)
+ server = GravitinoMCPServer(Setting("mock_view"))
+ self.mcp = server.mcp
+
+ def test_list_of_views(self):
+ async def _test_list_of_views(mcp_server):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_of_views",
+ {
+ "catalog_name": "mock_catalog",
+ "schema_name": "mock_schema",
+ },
+ )
+ self.assertEqual(
+ "mock_views: mock_catalog, mock_schema",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test_list_of_views(self.mcp))
+
+ def test_load_view(self):
+ async def _test_load_view(mcp_server):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "load_view",
+ {
+ "catalog_name": "mock_catalog",
+ "schema_name": "mock_schema",
+ "view_name": "mock_view",
+ },
+ )
+ self.assertEqual(
+ "mock_view: mock_catalog, mock_schema, mock_view",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test_load_view(self.mcp))