This is an automated email from the ASF dual-hosted git repository.
bharos 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 9e790c649d [#12099] feat(mcp-server): add read tools for table
partitions (#12128)
9e790c649d is described below
commit 9e790c649ddc421bf389f2105641c99f27fdc8e9
Author: Bharath Krishna <[email protected]>
AuthorDate: Thu Jul 23 10:26:05 2026 -0700
[#12099] feat(mcp-server): add read tools for table partitions (#12128)
### What changes were proposed in this pull request?
Add two read-only MCP tools for table partitions:
- `list_of_partitions(catalog, schema, table, details=false)` — returns
partition
names by default, or full partition metadata when `details=true`.
- `get_partition(catalog, schema, table, partition)` — returns the
metadata of a
single partition.
They follow the existing metadata-type pattern (interface → plain REST
client →
FastMCP tool → factory wiring → mock + unit tests). All user-supplied
path
segments are URL-encoded, and authorization is enforced by Gravitino
(`LOAD_TABLE`), so no filtering logic is added in the MCP layer.
### Why are the changes needed?
The server already exposes `list_statistics_for_partition`, which
requires
partition names as input, but there was no tool to discover those names.
These
tools close that gap and round out read coverage for partitioned tables.
Fix: #12099
### Does this PR introduce _any_ user-facing change?
Yes — two new MCP tools (`list_of_partitions`, `get_partition`), tagged
`partition`.
### How was this patch tested?
Added unit tests for the tools (`test_partition.py`) and for
URL-encoding /
query-param handling (`test_url_encoding.py`). Full unit suite passes
(163 tests); `black` and `pylint` (10/10) are clean.
---
docs/gravitino-mcp-server.md | 2 +
.../mcp_server/client/gravitino_operation.py | 11 ++
.../mcp_server/client/partition_operation.py | 70 ++++++++++++
.../client/plain/plain_rest_client_operation.py | 9 ++
.../plain/plain_rest_client_partition_operation.py | 65 +++++++++++
mcp-server/mcp_server/tools/__init__.py | 2 +
mcp-server/mcp_server/tools/partition.py | 119 +++++++++++++++++++++
mcp-server/tests/unit/client/test_url_encoding.py | 31 ++++++
mcp-server/tests/unit/tools/mock_operation.py | 30 ++++++
mcp-server/tests/unit/tools/test_partition.py | 92 ++++++++++++++++
10 files changed, 431 insertions(+)
diff --git a/docs/gravitino-mcp-server.md b/docs/gravitino-mcp-server.md
index 7d09a53730..769ffdc91f 100644
--- a/docs/gravitino-mcp-server.md
+++ b/docs/gravitino-mcp-server.md
@@ -124,6 +124,8 @@ Gravitino MCP server supports the following tools, and you
could export tool by
| `list_policies_for_metadata` | List all policies associated with a
specific metadata item. | `policy` | 1.0.0 |
| `list_metadata_by_policy` | List all metadata items associated
with a specific policy. | `policy` | 1.0.0 |
| `get_policy_for_metadata` | Get a policy associated with a
specific metadata item. | `policy` | 1.0.0 |
+| `list_of_partitions` | Retrieve partitions for a table. Only
for catalogs with a partition API. | `partition` | 2.0.0 |
+| `get_partition` | Retrieve a partition's metadata. Only
for catalogs with a partition API. | `partition` | 2.0.0 |
## Configuration
diff --git a/mcp-server/mcp_server/client/gravitino_operation.py
b/mcp-server/mcp_server/client/gravitino_operation.py
index a2e7fa152e..c8ff7fa719 100644
--- a/mcp-server/mcp_server/client/gravitino_operation.py
+++ b/mcp-server/mcp_server/client/gravitino_operation.py
@@ -21,6 +21,7 @@ from mcp_server.client.catalog_operation import
CatalogOperation
from mcp_server.client.fileset_operation import FilesetOperation
from mcp_server.client.job_operation import JobOperation
from mcp_server.client.model_operation import ModelOperation
+from mcp_server.client.partition_operation import PartitionOperation
from mcp_server.client.policy_operation import PolicyOperation
from mcp_server.client.schema_operation import SchemaOperation
from mcp_server.client.statistic_operation import StatisticOperation
@@ -132,3 +133,13 @@ class GravitinoOperation(ABC):
StatisticOperation: Interface for performing statistic-level
operations
"""
pass
+
+ @abstractmethod
+ def as_partition_operation(self) -> PartitionOperation:
+ """
+ Access the partition operation interface of this Gravitino operation.
+
+ Returns:
+ PartitionOperation: Interface for performing partition-level
operations
+ """
+ pass
diff --git a/mcp-server/mcp_server/client/partition_operation.py
b/mcp-server/mcp_server/client/partition_operation.py
new file mode 100644
index 0000000000..6831cb655d
--- /dev/null
+++ b/mcp-server/mcp_server/client/partition_operation.py
@@ -0,0 +1,70 @@
+# 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 PartitionOperation(ABC):
+ """
+ Abstract base class for Gravitino partition operations.
+ """
+
+ @abstractmethod
+ async def list_of_partitions(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ details: bool = False,
+ ) -> str:
+ """
+ Retrieve the partitions of a specific table.
+
+ Args:
+ catalog_name: Name of the catalog
+ schema_name: Name of the schema
+ table_name: Name of the table
+ details: When False, return partition names only. When True, return
+ full partition metadata.
+
+ Returns:
+ str: JSON-formatted string containing partition names or partition
+ metadata
+ """
+ pass
+
+ @abstractmethod
+ async def get_partition(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ partition_name: str,
+ ) -> str:
+ """
+ Load detailed information of a specific partition.
+
+ Args:
+ catalog_name: Name of the catalog
+ schema_name: Name of the schema
+ table_name: Name of the table
+ partition_name: Name of the partition
+
+ Returns:
+ str: JSON-formatted string containing full partition metadata
+ """
+ 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 a6adc0e099..a9e09bf2de 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
@@ -39,6 +39,9 @@ from mcp_server.client.plain.plain_rest_client_job_operation
import (
from mcp_server.client.plain.plain_rest_client_model_operation import (
PlainRESTClientModelOperation,
)
+from mcp_server.client.plain.plain_rest_client_partition_operation import (
+ PlainRESTClientPartitionOperation,
+)
from mcp_server.client.plain.plain_rest_client_policy_operation import (
PlainRESTClientPolicyOperation,
)
@@ -109,6 +112,9 @@ class PlainRESTClientOperation(GravitinoOperation):
self._statistic_operation = PlainRESTClientStatisticOperation(
metalake_name, _rest_client
)
+ self._partition_operation = PlainRESTClientPartitionOperation(
+ metalake_name, _rest_client
+ )
async def close(self) -> None:
"""Close the shared httpx client and release its connection pool."""
@@ -143,3 +149,6 @@ class PlainRESTClientOperation(GravitinoOperation):
def as_policy_operation(self) -> PolicyOperation:
return self._policy_operation
+
+ def as_partition_operation(self):
+ return self._partition_operation
diff --git
a/mcp-server/mcp_server/client/plain/plain_rest_client_partition_operation.py
b/mcp-server/mcp_server/client/plain/plain_rest_client_partition_operation.py
new file mode 100644
index 0000000000..33898413d4
--- /dev/null
+++
b/mcp-server/mcp_server/client/plain/plain_rest_client_partition_operation.py
@@ -0,0 +1,65 @@
+# 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.partition_operation import PartitionOperation
+from mcp_server.client.plain.utils import (
+ encode_path_segment,
+ extract_content_from_response,
+)
+
+
+class PlainRESTClientPartitionOperation(PartitionOperation):
+ """
+ Implementation of PartitionOperation 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_partitions(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ details: bool = False,
+ ) -> 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"/tables/{encode_path_segment(table_name)}/partitions",
+ params={"details": details},
+ )
+ field = "partitions" if details else "names"
+ return extract_content_from_response(response, field, [])
+
+ async def get_partition(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ partition_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"/tables/{encode_path_segment(table_name)}"
+ f"/partitions/{encode_path_segment(partition_name)}"
+ )
+ return extract_content_from_response(response, "partition", {})
diff --git a/mcp-server/mcp_server/tools/__init__.py
b/mcp-server/mcp_server/tools/__init__.py
index 5f754b495b..c44726b038 100644
--- a/mcp-server/mcp_server/tools/__init__.py
+++ b/mcp-server/mcp_server/tools/__init__.py
@@ -22,6 +22,7 @@ from mcp_server.tools.fileset import load_fileset_tools
from mcp_server.tools.job import load_job_tool
from mcp_server.tools.metadata import load_metadata_tool
from mcp_server.tools.model import load_model_tools
+from mcp_server.tools.partition import load_partition_tools
from mcp_server.tools.policy import load_policy_tools
from mcp_server.tools.schema import load_schema_tools
from mcp_server.tools.statistic import load_statistic_tools
@@ -42,3 +43,4 @@ def load_tools(mcp: FastMCP):
load_metadata_tool(mcp)
load_statistic_tools(mcp)
load_policy_tools(mcp)
+ load_partition_tools(mcp)
diff --git a/mcp-server/mcp_server/tools/partition.py
b/mcp-server/mcp_server/tools/partition.py
new file mode 100644
index 0000000000..dbe906e294
--- /dev/null
+++ b/mcp-server/mcp_server/tools/partition.py
@@ -0,0 +1,119 @@
+# 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_partition_tools(mcp: FastMCP):
+ @mcp.tool(tags={"partition"})
+ async def list_of_partitions(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ details: bool = False,
+ ):
+ """
+ Retrieve the partitions of a specific table.
+
+ Only catalogs that support the partition API (for example Hive) expose
+ partitions. Catalogs that use hidden partitioning, such as
+ lakehouse-iceberg, do not support this operation and return an
+ "unsupported operation" error; for those catalogs the partitioning is
+ described by the table's partition spec, available via
+ 'get_table_metadata_details'. Use the returned partition names as input
+ to partition-scoped tools such as 'list_statistics_for_partition' and
+ 'get_partition'.
+
+ Parameters:
+ ctx (Context): The request context object containing lifespan
context
+ and connector information.
+ catalog_name (str): The name of the catalog containing the table.
+ schema_name (str): The name of the schema containing the table.
+ table_name (str): The name of the table to list partitions for.
+ details (bool): When False (default), return partition names only.
+ When True, return the full metadata of every partition.
+
+ Returns:
+ str: When details is False, a JSON string containing an array of
+ partition names:
+ ["dt=2025-01-01", "dt=2025-01-02"]
+ When details is True, a JSON string containing an array of
+ partition objects, each with the following structure:
+ [
+ {
+ "type": "identity",
+ "name": "dt=2025-01-01",
+ "fieldNames": [["dt"]],
+ "values": [
+ {"type": "literal", "dataType": "date", "value":
"2025-01-01"}
+ ],
+ "properties": {}
+ }
+ ]
+
+ type: The partition type (identity, range, or list).
+ name: The unique name of the partition.
+ properties: A dictionary of partition-specific properties.
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_partition_operation().list_of_partitions(
+ catalog_name, schema_name, table_name, details
+ )
+
+ @mcp.tool(tags={"partition"})
+ async def get_partition(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ partition_name: str,
+ ):
+ """
+ Load detailed information of a specific partition.
+
+ Only catalogs that support the partition API (for example Hive) support
+ this operation; catalogs with hidden partitioning such as
+ lakehouse-iceberg return an "unsupported operation" error. Partition
+ names can be discovered with the 'list_of_partitions' tool.
+
+ Parameters:
+ ctx (Context): The request context object containing lifespan
context
+ and connector information.
+ catalog_name (str): The name of the catalog containing the table.
+ schema_name (str): The name of the schema containing the table.
+ table_name (str): The name of the table containing the partition.
+ partition_name (str): The name of the partition to load.
+
+ Returns:
+ str: A JSON string containing full partition metadata.
+
+ Example Return Value:
+ {
+ "type": "identity",
+ "name": "dt=2025-01-01",
+ "fieldNames": [["dt"]],
+ "values": [
+ {"type": "literal", "dataType": "date", "value": "2025-01-01"}
+ ],
+ "properties": {}
+ }
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_partition_operation().get_partition(
+ catalog_name, schema_name, table_name, partition_name
+ )
diff --git a/mcp-server/tests/unit/client/test_url_encoding.py
b/mcp-server/tests/unit/client/test_url_encoding.py
index 70de5dd831..75fc38c008 100644
--- a/mcp-server/tests/unit/client/test_url_encoding.py
+++ b/mcp-server/tests/unit/client/test_url_encoding.py
@@ -34,6 +34,9 @@ from mcp_server.client.plain.plain_rest_client_job_operation
import (
from mcp_server.client.plain.plain_rest_client_model_operation import (
PlainRESTClientModelOperation,
)
+from mcp_server.client.plain.plain_rest_client_partition_operation import (
+ PlainRESTClientPartitionOperation,
+)
from mcp_server.client.plain.plain_rest_client_policy_operation import (
PlainRESTClientPolicyOperation,
)
@@ -517,3 +520,31 @@ class TestStatisticOperationUrlEncoding(unittest.TestCase):
self.assertNotIn("../../", url)
self.assertIn("from", params)
self.assertIn("to", params)
+
+
+class TestPartitionOperationUrlEncoding(unittest.TestCase):
+ def test_list_of_partitions_encodes_table_name(self):
+ client = _make_mock_client({"names": []})
+ op = PlainRESTClientPartitionOperation(METALAKE, client)
+ asyncio.run(op.list_of_partitions("catalog", "schema",
_PATH_TRAVERSAL))
+ url = _called_url(client.get)
+ self.assertIn(_ENCODED_PATH_TRAVERSAL, url)
+ self.assertNotIn("../../", url)
+
+ def test_list_of_partitions_passes_details_as_query_param(self):
+ client = _make_mock_client({"partitions": []})
+ op = PlainRESTClientPartitionOperation(METALAKE, client)
+ asyncio.run(
+ op.list_of_partitions("catalog", "schema", "table", details=True)
+ )
+ self.assertTrue(_called_params(client.get)["details"])
+
+ def test_get_partition_encodes_partition_name(self):
+ client = _make_mock_client({"partition": {}})
+ op = PlainRESTClientPartitionOperation(METALAKE, client)
+ asyncio.run(
+ op.get_partition("catalog", "schema", "table", _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 e1953bc474..2abcd6fe57 100644
--- a/mcp-server/tests/unit/tools/mock_operation.py
+++ b/mcp-server/tests/unit/tools/mock_operation.py
@@ -27,6 +27,7 @@ from mcp_server.client import (
)
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
@@ -64,6 +65,9 @@ class MockOperation(GravitinoOperation):
def as_policy_operation(self) -> PolicyOperation:
return MockPolicyOperation()
+ def as_partition_operation(self) -> PartitionOperation:
+ return MockPartitionOperation()
+
class MockCatalogOperation(CatalogOperation):
async def get_list_of_catalogs(self) -> str:
@@ -440,3 +444,29 @@ class MockStatisticOperation(StatisticOperation):
f"mock_statistics_for_partition: {metalake_name}, {metadata_type},
{metadata_fullname},"
f" {from_partition_name}, {to_partition_name}, {from_inclusive},
{to_inclusive}"
)
+
+
+class MockPartitionOperation(PartitionOperation):
+ async def list_of_partitions(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ details: bool = False,
+ ) -> str:
+ return (
+ f"mock_partitions: {catalog_name}, {schema_name}, {table_name}, "
+ f"{details}"
+ )
+
+ async def get_partition(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ table_name: str,
+ partition_name: str,
+ ) -> str:
+ return (
+ f"mock_partition: {catalog_name}, {schema_name}, {table_name}, "
+ f"{partition_name}"
+ )
diff --git a/mcp-server/tests/unit/tools/test_partition.py
b/mcp-server/tests/unit/tools/test_partition.py
new file mode 100644
index 0000000000..080cbf9221
--- /dev/null
+++ b/mcp-server/tests/unit/tools/test_partition.py
@@ -0,0 +1,92 @@
+# 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 TestPartitionTool(unittest.TestCase):
+ def setUp(self):
+ RESTClientFactory.set_rest_client(MockOperation)
+ server = GravitinoMCPServer(Setting("mock_partition"))
+ self.mcp = server.mcp
+
+ def test_list_of_partitions(self):
+ async def _test_list_of_partitions(mcp_server):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_of_partitions",
+ {
+ "catalog_name": "mock_catalog",
+ "schema_name": "mock_schema",
+ "table_name": "mock_table",
+ },
+ )
+ self.assertEqual(
+ "mock_partitions: mock_catalog, mock_schema, mock_table, "
+ "False",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test_list_of_partitions(self.mcp))
+
+ def test_list_of_partitions_with_details(self):
+ async def _test_list_of_partitions_with_details(mcp_server):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_of_partitions",
+ {
+ "catalog_name": "mock_catalog",
+ "schema_name": "mock_schema",
+ "table_name": "mock_table",
+ "details": True,
+ },
+ )
+ self.assertEqual(
+ "mock_partitions: mock_catalog, mock_schema, mock_table, "
+ "True",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test_list_of_partitions_with_details(self.mcp))
+
+ def test_get_partition(self):
+ async def _test_get_partition(mcp_server):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_partition",
+ {
+ "catalog_name": "mock_catalog",
+ "schema_name": "mock_schema",
+ "table_name": "mock_table",
+ "partition_name": "mock_partition",
+ },
+ )
+ self.assertEqual(
+ "mock_partition: mock_catalog, mock_schema, mock_table, "
+ "mock_partition",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test_get_partition(self.mcp))