This is an automated email from the ASF dual-hosted git repository.
roryqi 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 c4453641e8 [#12176] feat(mcp-server): support policy-on-tag tools
(#13477)
c4453641e8 is described below
commit c4453641e8ec01c1c78e6c602e0319a054415e05
Author: roryqi <[email protected]>
AuthorDate: Wed Sep 30 09:33:10 2026 +0800
[#12176] feat(mcp-server): support policy-on-tag tools (#13477)
### What changes were proposed in this pull request?
Add MCP tools for managing policy-to-tag associations:
- List policies associated with a tag.
- Associate a policy with a tag and selector.
- Remove a policy association from a tag.
- List tags associated with a policy.
Also add REST client support, URL encoding coverage, unit tests, and MCP
documentation.
### Why are the changes needed?
Policy associations are now managed through tags instead of direct
metadata-object associations. The MCP server needs corresponding tools
so agents can manage and inspect policy-to-tag relationships.
Fix: #12176
### Does this PR introduce _any_ user-facing change?
Yes. The MCP server exposes four new tools:
- `list_policies_for_tag`
- `associate_policy_with_tag`
- `disassociate_policy_from_tag`
- `list_tags_for_policy`
### How was this patch tested?
- `./gradlew :mcp-server:testPython`
- 278 tests passed.
- `./gradlew :mcp-server:formatCheckPython`
- `git diff --check`
---
docs/gravitino-mcp-server.md | 135 +++++++++---------
.../plain/plain_rest_client_policy_operation.py | 42 +++++-
mcp-server/mcp_server/client/policy_operation.py | 54 +++++--
mcp-server/mcp_server/tools/policy.py | 157 +++++++++++++++------
mcp-server/tests/unit/client/test_url_encoding.py | 58 ++++++++
mcp-server/tests/unit/tools/mock_operation.py | 17 ++-
mcp-server/tests/unit/tools/test_policy.py | 83 +++++++++--
mcp-server/tests/unit/tools/test_statistic.py | 1 -
8 files changed, 413 insertions(+), 134 deletions(-)
diff --git a/docs/gravitino-mcp-server.md b/docs/gravitino-mcp-server.md
index fa8b539948..fb25fdafbd 100644
--- a/docs/gravitino-mcp-server.md
+++ b/docs/gravitino-mcp-server.md
@@ -69,72 +69,75 @@ You could start Gravitino MCP server by Docker image,
`docker run -p 8000:8000 -
Gravitino MCP server supports the following tools, and you could export tool
by tag.
-| Tool name | Description
| Tag |
-|-------------------------------------|--------------------------------------------------------------------------------|--------------|
-| `list_metalakes` | Retrieve the metalakes the caller can
access. | `metalake` |
-| `get_list_of_catalogs` | Retrieve a list of all catalogs in the
system. | `catalog` |
-| `create_catalog` | Create a new catalog.
| `catalog` |
-| `alter_catalog` | Alter an existing catalog.
| `catalog` |
-| `drop_catalog` | Drop a catalog.
| `catalog` |
-| `set_catalog_in_use` | Enable or disable a catalog.
| `catalog` |
-| `get_list_of_schemas` | Retrieve a list of schemas belonging
to a specific catalog. | `schema` |
-| `create_schema` | Create a new schema.
| `schema` |
-| `alter_schema` | Alter an existing schema.
| `schema` |
-| `drop_schema` | Drop a schema.
| `schema` |
-| `get_list_of_tables` | Retrieve a list of tables within a
specific catalog and schema. | `table` |
-| `get_table_metadata_details` | Retrieve comprehensive metadata
details for a specific table. | `table` |
-| `create_table` | Create a new table.
| `table` |
-| `alter_table` | Alter an existing table.
| `table` |
-| `drop_table` | Drop a table.
| `table` |
-| `list_of_models` | Retrieve a list of models within a
specific catalog and schema. | `model` |
-| `load_model` | Retrieve comprehensive metadata
details for a specific model. | `model` |
-| `list_model_versions` | Retrieve a list of versions for a
specific model. | `model` |
-| `load_model_version` | Retrieve comprehensive metadata
details for a specific model version. | `model` |
-| `load_model_version_by_alias` | Retrieve comprehensive metadata
details for a specific model version by alias. | `model` |
-| `register_model` | Register a new model.
| `model` |
-| `delete_model` | Delete a model.
| `model` |
-| `link_model_version` | Link a new version to a model.
| `model` |
-| `delete_model_version` | Delete a model version.
| `model` |
-| `delete_model_version_by_alias` | Delete a model version by one of its
aliases. | `model` |
-| `alter_model` | Alter an existing model.
| `model` |
-| `alter_model_version` | Alter a model version.
| `model` |
-| `alter_model_version_by_alias` | Alter a model version by one of its
aliases. | `model` |
-| `metadata_type_to_fullname_formats` | Retrieve the metadata type to fullname
formats mapping. | `metadata` |
-| `list_of_topics` | Retrieve a list of topics within a
specific catalog and schema. | `topic` |
-| `load_topic` | Retrieve comprehensive metadata
details for a specific topic. | `topic` |
-| `create_topic` | Create a new topic.
| `topic` |
-| `alter_topic` | Alter an existing topic.
| `topic` |
-| `delete_topic` | Delete a topic.
| `topic` |
-| `list_of_filesets` | Retrieve a list of filesets within a
specific catalog and schema. | `fileset` |
-| `load_fileset` | Retrieve comprehensive metadata
details for a specific fileset. | `fileset` |
-| `list_files_in_fileset` | Retrieve a list of files within a
specific fileset. | `fileset` |
-| `create_fileset` | Create a new fileset.
| `fileset` |
-| `alter_fileset` | Alter an existing fileset.
| `fileset` |
-| `drop_fileset` | Drop a fileset.
| `fileset` |
-| `list_of_jobs` | Retrieve a list of jobs
| `job` |
-| `get_job_by_id` | Retrieve a job by its ID.
| `job` |
-| `list_of_job_templates` | Retrieve a list of job templates.
| `job` |
-| `get_job_template_by_name` | Retrieve a job template by its name.
| `job` |
-| `run_job` | Run a job with the specified
parameters. | `job` |
-| `cancel_job` | Cancel a running job by its ID.
| `job` |
-| `get_tag_by_name` | Retrieve a tag by its name.
| `tag` |
-| `list_of_tags` | Retrieve a list of tags.
| `tag` |
-| `list_tags_for_metadata` | Retrieve a list of tags associated
with a specific metadata item. | `tag` |
-| `list_metadata_by_tag` | Retrieve a list of metadata items
associated with a specific tag. | `tag` |
-| `associate_tag_with_metadata` | Associate tags with a specific
metadata item. | `tag` |
-| `disassociate_tag_from_metadata` | Disassociate tags from a specific
metadata item. | `tag` |
-| `create_tag` | Create a new tag.
| `tag` |
-| `alter_tag` | Alter an existing tag.
| `tag` |
-| `delete_tag` | Delete a tag.
| `tag` |
-| `list_statistics_for_metadata` | Retrieve a list of statistics
associated with a specific metadata item. | `statistics` |
-| `list_statistics_for_partition` | Retrieve a list of statistics
associated with a specific partition. | `statistics` |
-| `get_list_of_policies` | Retrieve a list of policies in the
system. | `policy` |
-| `get_policy_detail_information` | Retrieve detailed information for a
specific policy by policy name. | `policy` |
-| `list_policies_for_metadata` | List all policies derived for 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` |
+| Tool name | Description
| Tag |
+|-------------------------------------|--------------------------------------------------------------------------------|-----------------|
+| `list_metalakes` | Retrieve the metalakes the caller can
access. | `metalake` |
+| `get_list_of_catalogs` | Retrieve a list of all catalogs in the
system. | `catalog` |
+| `create_catalog` | Create a new catalog.
| `catalog` |
+| `alter_catalog` | Alter an existing catalog.
| `catalog` |
+| `drop_catalog` | Drop a catalog.
| `catalog` |
+| `set_catalog_in_use` | Enable or disable a catalog.
| `catalog` |
+| `get_list_of_schemas` | Retrieve a list of schemas belonging
to a specific catalog. | `schema` |
+| `create_schema` | Create a new schema.
| `schema` |
+| `alter_schema` | Alter an existing schema.
| `schema` |
+| `drop_schema` | Drop a schema.
| `schema` |
+| `get_list_of_tables` | Retrieve a list of tables within a
specific catalog and schema. | `table` |
+| `get_table_metadata_details` | Retrieve comprehensive metadata
details for a specific table. | `table` |
+| `create_table` | Create a new table.
| `table` |
+| `alter_table` | Alter an existing table.
| `table` |
+| `drop_table` | Drop a table.
| `table` |
+| `list_of_models` | Retrieve a list of models within a
specific catalog and schema. | `model` |
+| `load_model` | Retrieve comprehensive metadata
details for a specific model. | `model` |
+| `list_model_versions` | Retrieve a list of versions for a
specific model. | `model` |
+| `load_model_version` | Retrieve comprehensive metadata
details for a specific model version. | `model` |
+| `load_model_version_by_alias` | Retrieve comprehensive metadata
details for a specific model version by alias. | `model` |
+| `register_model` | Register a new model.
| `model` |
+| `delete_model` | Delete a model.
| `model` |
+| `link_model_version` | Link a new version to a model.
| `model` |
+| `delete_model_version` | Delete a model version.
| `model` |
+| `delete_model_version_by_alias` | Delete a model version by one of its
aliases. | `model` |
+| `alter_model` | Alter an existing model.
| `model` |
+| `alter_model_version` | Alter a model version.
| `model` |
+| `alter_model_version_by_alias` | Alter a model version by one of its
aliases. | `model` |
+| `metadata_type_to_fullname_formats` | Retrieve the metadata type to fullname
formats mapping. | `metadata` |
+| `list_of_topics` | Retrieve a list of topics within a
specific catalog and schema. | `topic` |
+| `load_topic` | Retrieve comprehensive metadata
details for a specific topic. | `topic` |
+| `create_topic` | Create a new topic.
| `topic` |
+| `alter_topic` | Alter an existing topic.
| `topic` |
+| `delete_topic` | Delete a topic.
| `topic` |
+| `list_of_filesets` | Retrieve a list of filesets within a
specific catalog and schema. | `fileset` |
+| `load_fileset` | Retrieve comprehensive metadata
details for a specific fileset. | `fileset` |
+| `list_files_in_fileset` | Retrieve a list of files within a
specific fileset. | `fileset` |
+| `create_fileset` | Create a new fileset.
| `fileset` |
+| `alter_fileset` | Alter an existing fileset.
| `fileset` |
+| `drop_fileset` | Drop a fileset.
| `fileset` |
+| `list_of_jobs` | Retrieve a list of jobs
| `job` |
+| `get_job_by_id` | Retrieve a job by its ID.
| `job` |
+| `list_of_job_templates` | Retrieve a list of job templates.
| `job` |
+| `get_job_template_by_name` | Retrieve a job template by its name.
| `job` |
+| `run_job` | Run a job with the specified
parameters. | `job` |
+| `cancel_job` | Cancel a running job by its ID.
| `job` |
+| `get_tag_by_name` | Retrieve a tag by its name.
| `tag` |
+| `list_of_tags` | Retrieve a list of tags.
| `tag` |
+| `list_tags_for_metadata` | Retrieve a list of tags associated
with a specific metadata item. | `tag` |
+| `list_metadata_by_tag` | Retrieve a list of metadata items
associated with a specific tag. | `tag` |
+| `associate_tag_with_metadata` | Associate tags with a specific
metadata item. | `tag` |
+| `disassociate_tag_from_metadata` | Disassociate tags from a specific
metadata item. | `tag` |
+| `create_tag` | Create a new tag.
| `tag` |
+| `alter_tag` | Alter an existing tag.
| `tag` |
+| `delete_tag` | Delete a tag.
| `tag` |
+| `list_statistics_for_metadata` | Retrieve a list of statistics
associated with a specific metadata item. | `statistics` |
+| `list_statistics_for_partition` | Retrieve a list of statistics
associated with a specific partition. | `statistics` |
+| `get_list_of_policies` | Retrieve a list of policies in the
system. | `policy` |
+| `get_policy_detail_information` | Retrieve detailed information for a
specific policy by policy name. | `policy` |
+| `list_policies_for_tag` | List policies directly associated with
a tag, including selectors. | `policy`, `tag` |
+| `associate_policy_with_tag` | Associate a policy with a tag and
selector. | `policy`, `tag` |
+| `disassociate_policy_from_tag` | Remove a direct policy association
from a tag. | `policy`, `tag` |
+| `list_tags_for_policy` | List tags directly associated with a
policy, including selectors. | `policy`, `tag` |
+| `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/plain/plain_rest_client_policy_operation.py
b/mcp-server/mcp_server/client/plain/plain_rest_client_policy_operation.py
index 8d16e27cca..1c39384555 100644
--- a/mcp-server/mcp_server/client/plain/plain_rest_client_policy_operation.py
+++ b/mcp-server/mcp_server/client/plain/plain_rest_client_policy_operation.py
@@ -15,10 +15,13 @@
# specific language governing permissions and limitations
# under the License.
+import json
+
from mcp_server.client import PolicyOperation
from mcp_server.client.plain.utils import (
encode_path_segment,
extract_content_from_response,
+ extract_response,
)
@@ -46,12 +49,41 @@ class PlainRESTClientPolicyOperation(PolicyOperation):
)
return extract_content_from_response(response, "policy", {})
- async def list_policies_for_metadata(
- self, metadata_full_name: str, metadata_type: str
+ async def list_policies_for_tag(self, tag_name: str) -> str:
+ response = await self.rest_client.get(
+ f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
+ f"/tags/{encode_path_segment(tag_name)}/policies?details=true"
+ )
+ return extract_content_from_response(response, "associations", [])
+
+ async def associate_policy_with_tag(
+ self, tag_name: str, policy_name: str, selector: dict
) -> str:
+ response = await self.rest_client.post(
+ f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
+ f"/tags/{encode_path_segment(tag_name)}"
+ f"/policies/{encode_path_segment(policy_name)}",
+ json={"selector": selector},
+ )
+ return extract_response(response)
+
+ async def disassociate_policy_from_tag(
+ self, tag_name: str, policy_name: str
+ ) -> str:
+ response = await self.rest_client.delete(
+ f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
+ f"/tags/{encode_path_segment(tag_name)}"
+ f"/policies/{encode_path_segment(policy_name)}"
+ )
+ if response.status_code == 204:
+ return json.dumps(
+ {"policy": policy_name, "tag": tag_name, "removed": True}
+ )
+ return extract_response(response)
+
+ async def list_tags_for_policy(self, policy_name: str) -> str:
response = await self.rest_client.get(
f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
- f"/objects/{encode_path_segment(metadata_type)}"
-
f"/{encode_path_segment(metadata_full_name)}/policies?details=true",
+ f"/policies/{encode_path_segment(policy_name)}/tags?details=true"
)
- return extract_content_from_response(response, "policies", [])
+ return extract_content_from_response(response, "associations", [])
diff --git a/mcp-server/mcp_server/client/policy_operation.py
b/mcp-server/mcp_server/client/policy_operation.py
index 00ce3b45c6..0f23d1862f 100644
--- a/mcp-server/mcp_server/client/policy_operation.py
+++ b/mcp-server/mcp_server/client/policy_operation.py
@@ -47,20 +47,56 @@ class PolicyOperation(ABC):
pass
@abstractmethod
- async def list_policies_for_metadata(
- self, metadata_full_name: str, metadata_type: str
+ async def list_policies_for_tag(self, tag_name: str) -> str:
+ """List all policies directly associated with a tag.
+
+ Args:
+ tag_name: Name of the tag
+
+ Returns:
+ str: JSON-formatted list of policy-tag associations, including
selectors
+ """
+ pass
+
+ @abstractmethod
+ async def associate_policy_with_tag(
+ self, tag_name: str, policy_name: str, selector: dict
) -> str:
+ """Associate one policy with a tag.
+
+ Args:
+ tag_name: Name of the tag
+ policy_name: Name of the policy
+ selector: Selector controlling which tag assignments match the
policy
+
+ Returns:
+ str: JSON-formatted policy-tag association
"""
- List all policies associated with a specific metadata item.
+ pass
+
+ @abstractmethod
+ async def disassociate_policy_from_tag(
+ self, tag_name: str, policy_name: str
+ ) -> str:
+ """Remove one policy association from a tag.
Args:
- metadata_full_name: Full name of the metadata object to associate
policies with.
- It's typically in the format "catalog.schema.table" or
"catalog.schema" or "catalog"
- or "catalog.schema.fileset". The "model", "topic" are also
supported and the format
- is the same as for "catalog.schema.table".
- metadata_type: Type of the metadata (e.g., "table", "column")
+ tag_name: Name of the tag
+ policy_name: Name of the policy
+
+ Returns:
+ str: JSON-formatted removal confirmation
+ """
+ pass
+
+ @abstractmethod
+ async def list_tags_for_policy(self, policy_name: str) -> str:
+ """List all tags directly associated with a policy.
+
+ Args:
+ policy_name: Name of the policy
Returns:
- str: JSON formatted string containing list of policy metadata
associated with the metadata
+ str: JSON-formatted list of policy-tag associations, including
selectors
"""
pass
diff --git a/mcp-server/mcp_server/tools/policy.py
b/mcp-server/mcp_server/tools/policy.py
index 0ec1672d49..5508e00d05 100644
--- a/mcp-server/mcp_server/tools/policy.py
+++ b/mcp-server/mcp_server/tools/policy.py
@@ -132,60 +132,133 @@ def load_policy_tools(mcp: FastMCP):
client = ctx.request_context.lifespan_context.rest_client()
return await client.as_policy_operation().load_policy(policy_name)
- @mcp.tool(tags={"policy"})
- async def list_policies_for_metadata(
- ctx: Context, metadata_full_name: str, metadata_type: str
+ @mcp.tool(tags={"policy", "tag"})
+ async def list_policies_for_tag(ctx: Context, tag_name: str) -> str:
+ """List the policies directly associated with a tag.
+
+ The result includes each policy and the selector on its association.
+ ALL_VALUES selectors match any assignment of the tag, while TAG_VALUE
+ selectors match only the specified assignment value.
+
+ Args:
+ ctx (Context): The request context containing the REST client.
+ tag_name (str): Name of the tag.
+
+ Returns:
+ str: JSON-formatted policy-tag associations.
+
+ Example Return Value:
+ [
+ {
+ "policy": {
+ "name": "retention_policy",
+ "policyType": "custom",
+ "enabled": true
+ },
+ "selector": {
+ "type": "TAG_VALUE",
+ "value": "finance"
+ }
+ }
+ ]
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_policy_operation().list_policies_for_tag(
+ tag_name
+ )
+
+ @mcp.tool(tags={"policy", "tag"})
+ async def associate_policy_with_tag(
+ ctx: Context,
+ tag_name: str,
+ policy_name: str,
+ selector: dict,
) -> str:
+ """Associate one policy with a tag and a selector.
+
+ Use {"type": "ALL_VALUES"} to select the policy whenever the tag is
+ present. Use {"type": "TAG_VALUE", "value": "finance"} to select it
+ only when the tag is assigned with that value.
+
+ Args:
+ ctx (Context): The request context containing the REST client.
+ tag_name (str): Name of the tag.
+ policy_name (str): Name of the policy.
+ selector (dict): Required association selector.
+
+ Returns:
+ str: JSON-formatted policy-tag association.
+
+ Example Return Value:
+ {
+ "code": 0,
+ "policy": "retention_policy",
+ "tag": "data_domain",
+ "selector": {
+ "type": "TAG_VALUE",
+ "value": "finance"
+ }
+ }
"""
- List all policies associated with a specific metadata item.
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_policy_operation().associate_policy_with_tag(
+ tag_name, policy_name, selector
+ )
+
+ @mcp.tool(tags={"policy", "tag"})
+ async def disassociate_policy_from_tag(
+ ctx: Context, tag_name: str, policy_name: str
+ ) -> str:
+ """Remove one direct policy association from a tag.
Args:
- ctx (Context): The request context object containing lifespan
context
- and connector information.
- metadata_full_name (str): Full name of the metadata item. For
more, please see tool
- `get_metadata_fullname_formats`.
- metadata_type (str): Type of the metadata (e.g., "table",
"column"). For More information, please see
- tool `list_all_metadata_types`.
+ ctx (Context): The request context containing the REST client.
+ tag_name (str): Name of the tag.
+ policy_name (str): Name of the policy.
- Example input:
- metadata_full_name: "catalog.schema.table"
- metadata_type: "table"
+ Returns:
+ str: JSON-formatted removal confirmation.
+
+ Example Return Value:
+ {
+ "policy": "retention_policy",
+ "tag": "data_domain",
+ "removed": true
+ }
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_policy_operation().disassociate_policy_from_tag(
+ tag_name, policy_name
+ )
+
+ @mcp.tool(tags={"policy", "tag"})
+ async def list_tags_for_policy(ctx: Context, policy_name: str) -> str:
+ """List the tags directly associated with a policy.
+
+ The result includes each tag and the selector on its association.
+
+ Args:
+ ctx (Context): The request context containing the REST client.
+ policy_name (str): Name of the policy.
Returns:
- str: JSON-formatted string containing the list of policies
associated with the metadata.
+ str: JSON-formatted policy-tag associations.
Example Return Value:
[
- {
- "name": "my_policy1",
- "comment": "This is a test policy",
- "policyType": "custom",
- "enabled": true,
- "content": {
- "customRules": {
- "rule1": 123
- },
- "properties": {
- "key1": "value1"
- },
- "supportedObjectTypes": [
- "fileset",
- "model",
- "topic",
- "schema",
- "table",
- "catalog"
- ]
- },
- "inherited": false,
- "audit": {
- "creator": "anonymous",
- "createTime": "2025-08-18T08:29:30.016501Z"
- }
+ {
+ "tag": {
+ "name": "data_domain",
+ "comment": "Business data domain"
+ },
+ "selector": {
+ "type": "TAG_VALUE",
+ "value": "finance"
}
+ }
]
"""
client = ctx.request_context.lifespan_context.rest_client()
- return await client.as_policy_operation().list_policies_for_metadata(
- metadata_full_name, metadata_type
+ return await client.as_policy_operation().list_tags_for_policy(
+ policy_name
)
diff --git a/mcp-server/tests/unit/client/test_url_encoding.py
b/mcp-server/tests/unit/client/test_url_encoding.py
index 3b84d32544..e4450b9f01 100644
--- a/mcp-server/tests/unit/client/test_url_encoding.py
+++ b/mcp-server/tests/unit/client/test_url_encoding.py
@@ -496,6 +496,64 @@ class TestPolicyOperationUrlEncoding(unittest.TestCase):
self.assertIn(_ENCODED_PATH_TRAVERSAL, url)
self.assertNotIn("../../", url)
+ def test_list_policies_for_tag_encodes_tag_name(self):
+ client = _make_mock_client({"associations": [{"policy": {}}]})
+ op = PlainRESTClientPolicyOperation(METALAKE, client)
+ result = asyncio.run(op.list_policies_for_tag(_PATH_TRAVERSAL))
+ url = _called_url(client.get)
+ self.assertEqual('[{"policy": {}}]', result)
+ self.assertIn(_ENCODED_PATH_TRAVERSAL, url)
+ self.assertIn("details=true", url)
+ self.assertNotIn("../../", url)
+
+ def test_associate_policy_with_tag_encodes_names_and_sends_selector(self):
+ client = _make_mock_client(
+ {
+ "code": 0,
+ "policy": _QUERY_INJECTION,
+ "tag": _PATH_TRAVERSAL,
+ "selector": {"type": "ALL_VALUES"},
+ }
+ )
+ op = PlainRESTClientPolicyOperation(METALAKE, client)
+ result = asyncio.run(
+ op.associate_policy_with_tag(
+ _PATH_TRAVERSAL,
+ _QUERY_INJECTION,
+ {"type": "ALL_VALUES"},
+ )
+ )
+ url = _called_url(client.post)
+ self.assertIn('"selector": {"type": "ALL_VALUES"}', result)
+ self.assertIn(_ENCODED_PATH_TRAVERSAL, url)
+ self.assertIn(_ENCODED_QUERY_INJECTION, url)
+ self.assertEqual(
+ {"selector": {"type": "ALL_VALUES"}},
+ client.post.call_args.kwargs["json"],
+ )
+
+ def test_disassociate_policy_from_tag_handles_no_content_response(self):
+ client = _make_mock_client({})
+ client.delete.return_value.status_code = 204
+ op = PlainRESTClientPolicyOperation(METALAKE, client)
+ result = asyncio.run(
+ op.disassociate_policy_from_tag(_PATH_TRAVERSAL, _QUERY_INJECTION)
+ )
+ url = _called_url(client.delete)
+ self.assertIn('"removed": true', result)
+ self.assertIn(_ENCODED_PATH_TRAVERSAL, url)
+ self.assertIn(_ENCODED_QUERY_INJECTION, url)
+
+ def test_list_tags_for_policy_encodes_policy_name(self):
+ client = _make_mock_client({"associations": [{"tag": {}}]})
+ op = PlainRESTClientPolicyOperation(METALAKE, client)
+ result = asyncio.run(op.list_tags_for_policy(_QUERY_INJECTION))
+ url = _called_url(client.get)
+ self.assertEqual('[{"tag": {}}]', result)
+ self.assertIn(_ENCODED_QUERY_INJECTION, url)
+ self.assertIn("details=true", url)
+ self.assertNotIn("?admin=true", url)
+
class TestStatisticOperationUrlEncoding(unittest.TestCase):
def test_list_of_statistics_encodes_metadata_fullname(self):
diff --git a/mcp-server/tests/unit/tools/mock_operation.py
b/mcp-server/tests/unit/tools/mock_operation.py
index dfe6125525..371e125509 100644
--- a/mcp-server/tests/unit/tools/mock_operation.py
+++ b/mcp-server/tests/unit/tools/mock_operation.py
@@ -225,13 +225,24 @@ class MockFilesetOperation(FilesetOperation):
class MockPolicyOperation(PolicyOperation):
- async def list_policies_for_metadata(
- self, metadata_full_name: str, metadata_type: str
+ async def list_policies_for_tag(self, tag_name: str) -> str:
+ return f"list_policies_for_tag: {tag_name}"
+
+ async def associate_policy_with_tag(
+ self, tag_name: str, policy_name: str, selector: dict
) -> str:
return (
- f"list_policies_for_metadata: {metadata_full_name},
{metadata_type}"
+ f"associate_policy_with_tag: {tag_name}, {policy_name}, {selector}"
)
+ async def disassociate_policy_from_tag(
+ self, tag_name: str, policy_name: str
+ ) -> str:
+ return f"disassociate_policy_from_tag: {tag_name}, {policy_name}"
+
+ async def list_tags_for_policy(self, policy_name: str) -> str:
+ return f"list_tags_for_policy: {policy_name}"
+
async def get_list_of_policies(self) -> str:
return "mock_policies"
diff --git a/mcp-server/tests/unit/tools/test_policy.py
b/mcp-server/tests/unit/tools/test_policy.py
index fc49b5e312..64c21fd9f5 100644
--- a/mcp-server/tests/unit/tools/test_policy.py
+++ b/mcp-server/tests/unit/tools/test_policy.py
@@ -54,28 +54,95 @@ class TestPolicyTool(unittest.TestCase):
asyncio.run(_test_get_policy_detail_information(self.mcp))
- def test_list_policies_for_metadata(self):
- async def _test_list_policies_for_metadata(mcp_server):
- async with Client(mcp_server) as client:
+ def test_list_policies_for_tag(self):
+ async def _test():
+ async with Client(self.mcp) as client:
result = await client.call_tool(
- "list_policies_for_metadata",
+ "list_policies_for_tag", {"tag_name": "data_domain"}
+ )
+ self.assertEqual(
+ "list_policies_for_tag: data_domain",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test())
+
+ def test_associate_policy_with_tag(self):
+ async def _test():
+ async with Client(self.mcp) as client:
+ result = await client.call_tool(
+ "associate_policy_with_tag",
+ {
+ "tag_name": "data_domain",
+ "policy_name": "retention_policy",
+ "selector": {
+ "type": "TAG_VALUE",
+ "value": "finance",
+ },
+ },
+ )
+ self.assertEqual(
+ "associate_policy_with_tag: data_domain, "
+ "retention_policy, {'type': 'TAG_VALUE', "
+ "'value': 'finance'}",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test())
+
+ def test_disassociate_policy_from_tag(self):
+ async def _test():
+ async with Client(self.mcp) as client:
+ result = await client.call_tool(
+ "disassociate_policy_from_tag",
{
- "metadata_full_name": "catalog.db.table",
- "metadata_type": "table",
+ "tag_name": "data_domain",
+ "policy_name": "retention_policy",
},
)
self.assertEqual(
- "list_policies_for_metadata: catalog.db.table, table",
+ "disassociate_policy_from_tag: data_domain, "
+ "retention_policy",
result.content[0].text,
)
- asyncio.run(_test_list_policies_for_metadata(self.mcp))
+ asyncio.run(_test())
+
+ def test_list_tags_for_policy(self):
+ async def _test():
+ async with Client(self.mcp) as client:
+ result = await client.call_tool(
+ "list_tags_for_policy",
+ {"policy_name": "retention_policy"},
+ )
+ self.assertEqual(
+ "list_tags_for_policy: retention_policy",
+ result.content[0].text,
+ )
+
+ asyncio.run(_test())
+
+ def test_policy_tag_tools_are_exposed(self):
+ async def _test():
+ async with Client(self.mcp) as client:
+ names = {tool.name for tool in await client.list_tools()}
+ self.assertTrue(
+ {
+ "list_policies_for_tag",
+ "associate_policy_with_tag",
+ "disassociate_policy_from_tag",
+ "list_tags_for_policy",
+ }.issubset(names)
+ )
+
+ asyncio.run(_test())
def test_removed_object_policy_tools_are_not_exposed(self):
async def _test():
async with Client(self.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
for name in (
+ "list_policies_for_metadata",
"associate_policy_with_metadata",
"disassociate_policy_from_metadata",
"get_policy_for_metadata",
diff --git a/mcp-server/tests/unit/tools/test_statistic.py
b/mcp-server/tests/unit/tools/test_statistic.py
index 4f9a385baa..7768b9f9e2 100644
--- a/mcp-server/tests/unit/tools/test_statistic.py
+++ b/mcp-server/tests/unit/tools/test_statistic.py
@@ -80,7 +80,6 @@ class TestStatisticTool(unittest.TestCase):
"associate_tag_with_metadata",
"disassociate_tag_from_metadata",
"list_tags_for_metadata",
- "list_policies_for_metadata",
):
with self.subTest(tool=name):
schema = tools[name].inputSchema