This is an automated email from the ASF dual-hosted git repository.
mchades 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 61addf63ed [#13775] feat(mcp-server): Add Semantic Model tools (#13804)
61addf63ed is described below
commit 61addf63ed42f3c355778f92d5693f6d4ee1d89f
Author: mchades <[email protected]>
AuthorDate: Tue Oct 13 23:15:32 2026 +0800
[#13775] feat(mcp-server): Add Semantic Model tools (#13804)
### What changes were proposed in this pull request?
Add schema-scoped Semantic Model list, load, create, alter, and drop
tools using the existing REST client. Register the `semantic_model` tag,
document usage, and reject ambiguous REST path names.
### Why are the changes needed?
Allow MCP clients to discover and manage Semantic Models.
Fix: #13775
### Does this PR introduce _any_ user-facing change?
Yes. Five new MCP tools are available under the `semantic_model` tag.
### How was this patch tested?
- Added 12 tests covering REST contracts, path handling, identity and
metalake selection, errors, and tool registration.
- `./gradlew :mcp-server:spotlessCheck :mcp-server:test
:mcp-server:pylint -PskipITs` passed: 289 tests, formatting, and lint.
- `git diff --check` passed.
Live-server E2E tests were not run.
---
docs/gravitino-mcp-server.md | 51 +++
.../mcp_server/client/gravitino_operation.py | 6 +
.../client/plain/plain_rest_client_operation.py | 10 +
.../plain_rest_client_semantic_model_operation.py | 106 ++++++
mcp-server/mcp_server/client/plain/utils.py | 8 +-
.../mcp_server/client/semantic_model_operation.py | 69 ++++
mcp-server/mcp_server/tools/__init__.py | 3 +
mcp-server/mcp_server/tools/semantic_model.py | 160 ++++++++
mcp-server/tests/unit/client/test_url_encoding.py | 58 +++
mcp-server/tests/unit/tools/mock_operation.py | 44 +++
mcp-server/tests/unit/tools/test_semantic_model.py | 424 +++++++++++++++++++++
11 files changed, 937 insertions(+), 2 deletions(-)
diff --git a/docs/gravitino-mcp-server.md b/docs/gravitino-mcp-server.md
index fb25fdafbd..71410670d8 100644
--- a/docs/gravitino-mcp-server.md
+++ b/docs/gravitino-mcp-server.md
@@ -138,7 +138,58 @@ Gravitino MCP server supports the following tools, and you
could export tool by
| `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` |
+| `list_of_semantic_models` | List the Semantic Models the caller
can access in a schema. | `semantic_model` |
+| `load_semantic_model` | Load a Semantic Model's metadata and
definition. | `semantic_model` |
+| `create_semantic_model` | Create a Semantic Model from a native
REST definition. | `semantic_model` |
+| `alter_semantic_model` | Apply Semantic Model updates
atomically. | `semantic_model` |
+| `drop_semantic_model` | Drop a Semantic Model's metadata.
| `semantic_model` |
+### Semantic Models
+
+Semantic Model tools manage schema-scoped semantic metadata through the
Gravitino REST API.
+Use the `semantic_model` tag with `--include-tool-tags` to expose these five
tools.
+They use the same per-call `metalake` selection and authentication as other
tools;
+Gravitino filters listings and enforces authorization, including access to
referenced source tables.
+Names used in REST paths must be non-empty and cannot be `.` or `..`, which
HTTP clients
+normalize as path navigation. Names must also satisfy the server's naming
rules.
+
+For example, call `create_semantic_model` with the following arguments after
the
+`sales.mart.orders` source table exists in the selected metalake:
+
+```json
+{
+ "metalake": "prod",
+ "catalog_name": "sales",
+ "schema_name": "mart",
+ "name": "sales_model",
+ "definition": {
+ "datasets": [
+ {
+ "name": "orders",
+ "source": {"namespace": ["sales", "mart"], "name": "orders"},
+ "fields": []
+ }
+ ]
+ },
+ "comment": "Governed sales definitions",
+ "properties": {"domain": "sales"}
+}
+```
+
+`definition` uses the native REST format with camelCase field names. It
requires a
+non-empty `datasets` array and supports optional `relationships`, `metrics`,
+`aiContext`, and `customExtensions` fields.
+`comment` and `properties` are optional. Load, create, and alter return the
model object
+with its definition, properties, and audit information.
+
+`alter_semantic_model` accepts `catalog_name`, `schema_name`,
`semantic_model_name`, and
+an ordered, non-empty `updates` array. Supported `@type` values are `rename`,
+`updateComment`, `setProperty`, `removeProperty`, and `replaceDefinition`. For
example,
+`[{"@type": "updateComment", "newComment": null}]` clears the comment.
+`replaceDefinition` requires the complete native definition and revalidates
source access.
+
+`drop_semantic_model` takes the model's catalog, schema, and name and returns
a JSON boolean
+indicating whether it was dropped. It removes semantic metadata without
deleting source tables or data.
## Configuration
diff --git a/mcp-server/mcp_server/client/gravitino_operation.py
b/mcp-server/mcp_server/client/gravitino_operation.py
index 763ef02da8..15114c2d68 100644
--- a/mcp-server/mcp_server/client/gravitino_operation.py
+++ b/mcp-server/mcp_server/client/gravitino_operation.py
@@ -25,6 +25,7 @@ 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.semantic_model_operation import SemanticModelOperation
from mcp_server.client.statistic_operation import StatisticOperation
from mcp_server.client.table_operation import TableOperation
from mcp_server.client.tag_operation import TagOperation
@@ -156,6 +157,11 @@ class GravitinoOperation(ABC):
"""
pass
+ @abstractmethod
+ def as_semantic_model_operation(self) -> SemanticModelOperation:
+ """Access the schema-scoped Semantic Model operation interface."""
+ pass
+
@abstractmethod
def as_metalake_operation(self) -> MetalakeOperation:
"""
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 63187bedea..74afefdac2 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
@@ -54,6 +54,9 @@ from
mcp_server.client.plain.plain_rest_client_policy_operation import (
from mcp_server.client.plain.plain_rest_client_schema_operation import (
PlainRESTClientSchemaOperation,
)
+from mcp_server.client.plain.plain_rest_client_semantic_model_operation import
(
+ PlainRESTClientSemanticModelOperation,
+)
from mcp_server.client.plain.plain_rest_client_statistic_operation import (
PlainRESTClientStatisticOperation,
)
@@ -69,6 +72,7 @@ from
mcp_server.client.plain.plain_rest_client_topic_operation import (
from mcp_server.client.plain.plain_rest_client_view_operation import (
PlainRESTClientViewOperation,
)
+from mcp_server.client.semantic_model_operation import SemanticModelOperation
from mcp_server.client.topic_operation import TopicOperation
@@ -138,6 +142,9 @@ class PlainRESTClientOperation(GravitinoOperation):
self._view_operation = PlainRESTClientViewOperation(
metalake_name, _rest_client
)
+ self._semantic_model_operation = PlainRESTClientSemanticModelOperation(
+ metalake_name, _rest_client
+ )
# Not metalake-scoped: addresses the server's top-level endpoint.
self._metalake_operation = PlainRESTClientMetalakeOperation(
_rest_client
@@ -185,3 +192,6 @@ class PlainRESTClientOperation(GravitinoOperation):
def as_view_operation(self):
return self._view_operation
+
+ def as_semantic_model_operation(self) -> SemanticModelOperation:
+ return self._semantic_model_operation
diff --git
a/mcp-server/mcp_server/client/plain/plain_rest_client_semantic_model_operation.py
b/mcp-server/mcp_server/client/plain/plain_rest_client_semantic_model_operation.py
new file mode 100644
index 0000000000..d02b80514d
--- /dev/null
+++
b/mcp-server/mcp_server/client/plain/plain_rest_client_semantic_model_operation.py
@@ -0,0 +1,106 @@
+# 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 typing import Optional
+
+from httpx import AsyncClient
+
+from mcp_server.client.plain.utils import (
+ encode_path_segment,
+ extract_content_from_response,
+)
+from mcp_server.client.semantic_model_operation import SemanticModelOperation
+
+
+class PlainRESTClientSemanticModelOperation(SemanticModelOperation):
+ """Access Semantic Models through Gravitino's native REST API."""
+
+ def __init__(self, metalake_name: str, rest_client: AsyncClient):
+ self.metalake_name = metalake_name
+ self.rest_client = rest_client
+
+ async def list_of_semantic_models(
+ self, catalog_name: str, schema_name: str
+ ) -> str:
+ response = await self.rest_client.get(
+ self._models_path(catalog_name, schema_name)
+ )
+ return extract_content_from_response(response, "identifiers", [])
+
+ async def load_semantic_model(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ response = await self.rest_client.get(
+ self._model_path(catalog_name, schema_name, semantic_model_name)
+ )
+ return extract_content_from_response(response, "semanticModel", {})
+
+ # pylint: disable=too-many-positional-arguments
+ async def create_semantic_model(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ name: str,
+ definition: dict,
+ comment: Optional[str] = None,
+ properties: Optional[dict[str, str]] = None,
+ ) -> str:
+ body = {"name": name, "definition": definition}
+ if comment is not None:
+ body["comment"] = comment
+ if properties is not None:
+ body["properties"] = properties
+ response = await self.rest_client.post(
+ self._models_path(catalog_name, schema_name), json=body
+ )
+ return extract_content_from_response(response, "semanticModel", {})
+
+ async def alter_semantic_model(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ semantic_model_name: str,
+ updates: list[dict],
+ ) -> str:
+ response = await self.rest_client.put(
+ self._model_path(catalog_name, schema_name, semantic_model_name),
+ json={"updates": updates},
+ )
+ return extract_content_from_response(response, "semanticModel", {})
+
+ async def drop_semantic_model(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ response = await self.rest_client.delete(
+ self._model_path(catalog_name, schema_name, semantic_model_name)
+ )
+ return extract_content_from_response(response, "dropped", False)
+
+ def _models_path(self, catalog_name: str, schema_name: str) -> str:
+ return (
+ f"/api/metalakes/{encode_path_segment(self.metalake_name)}"
+ f"/catalogs/{encode_path_segment(catalog_name)}"
+ f"/schemas/{encode_path_segment(schema_name)}/semantic-models"
+ )
+
+ def _model_path(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ return (
+ f"{self._models_path(catalog_name, schema_name)}"
+ f"/{encode_path_segment(semantic_model_name)}"
+ )
diff --git a/mcp-server/mcp_server/client/plain/utils.py
b/mcp-server/mcp_server/client/plain/utils.py
index 5f9b5eb41a..298c89170e 100644
--- a/mcp-server/mcp_server/client/plain/utils.py
+++ b/mcp-server/mcp_server/client/plain/utils.py
@@ -23,8 +23,12 @@ from mcp_server.client.plain.exception import
GravitinoException
def encode_path_segment(segment: str) -> str:
- """URL-encode a single path segment, encoding all reserved characters."""
- return quote(str(segment), safe="")
+ """Validate and URL-encode a single REST path segment."""
+ segment = str(segment)
+ # HTTP clients normalize dot segments, which can target another resource.
+ if segment in ("", ".", ".."):
+ raise ValueError("REST path names must not be empty, '.' or '..'")
+ return quote(segment, safe="")
def extract_content_from_response(response, field: str, default="") -> str:
diff --git a/mcp-server/mcp_server/client/semantic_model_operation.py
b/mcp-server/mcp_server/client/semantic_model_operation.py
new file mode 100644
index 0000000000..deeb83cf30
--- /dev/null
+++ b/mcp-server/mcp_server/client/semantic_model_operation.py
@@ -0,0 +1,69 @@
+# 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
+from typing import Optional
+
+
+class SemanticModelOperation(ABC):
+ """Operations on schema-scoped Gravitino Semantic Models."""
+
+ @abstractmethod
+ async def list_of_semantic_models(
+ self, catalog_name: str, schema_name: str
+ ) -> str:
+ """Return Semantic Model identifiers as a JSON array."""
+ pass
+
+ @abstractmethod
+ async def load_semantic_model(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ """Return the Semantic Model's metadata and definition as JSON."""
+ pass
+
+ # pylint: disable=too-many-positional-arguments
+ @abstractmethod
+ async def create_semantic_model(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ name: str,
+ definition: dict,
+ comment: Optional[str] = None,
+ properties: Optional[dict[str, str]] = None,
+ ) -> str:
+ """Create a Semantic Model with a native REST definition."""
+ pass
+
+ @abstractmethod
+ async def alter_semantic_model(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ semantic_model_name: str,
+ updates: list[dict],
+ ) -> str:
+ """Apply native REST update operations atomically and return JSON."""
+ pass
+
+ @abstractmethod
+ async def drop_semantic_model(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ """Return a JSON boolean indicating whether the model was dropped."""
+ pass
diff --git a/mcp-server/mcp_server/tools/__init__.py
b/mcp-server/mcp_server/tools/__init__.py
index 4fd524c088..86562bdf12 100644
--- a/mcp-server/mcp_server/tools/__init__.py
+++ b/mcp-server/mcp_server/tools/__init__.py
@@ -26,6 +26,7 @@ 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.semantic_model import load_semantic_model_tools
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
@@ -43,6 +44,7 @@ SUPPORTED_TOOL_TAGS = frozenset(
"partition",
"policy",
"schema",
+ "semantic_model",
"statistic",
"table",
"tag",
@@ -67,3 +69,4 @@ def load_tools(mcp: FastMCP):
load_policy_tools(mcp)
load_partition_tools(mcp)
load_view_tools(mcp)
+ load_semantic_model_tools(mcp)
diff --git a/mcp-server/mcp_server/tools/semantic_model.py
b/mcp-server/mcp_server/tools/semantic_model.py
new file mode 100644
index 0000000000..2a5353508a
--- /dev/null
+++ b/mcp-server/mcp_server/tools/semantic_model.py
@@ -0,0 +1,160 @@
+# 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 typing import Optional
+
+from fastmcp import Context, FastMCP
+
+
+def load_semantic_model_tools(mcp: FastMCP):
+ @mcp.tool(tags={"semantic_model"})
+ async def list_of_semantic_models(
+ ctx: Context, catalog_name: str, schema_name: str
+ ) -> str:
+ """List the Semantic Models the caller can access in a schema.
+
+ Args:
+ ctx: The request context.
+ catalog_name: Name of the catalog.
+ schema_name: Name of the schema.
+
+ Returns:
+ A JSON array of identifiers, each with a namespace
+ [metalake, catalog, schema] and a name.
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return (
+ await client.as_semantic_model_operation().list_of_semantic_models(
+ catalog_name, schema_name
+ )
+ )
+
+ @mcp.tool(tags={"semantic_model"})
+ async def load_semantic_model(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ semantic_model_name: str,
+ ) -> str:
+ """Load a Semantic Model's metadata and complete semantic definition.
+
+ Args:
+ ctx: The request context.
+ catalog_name: Name of the catalog.
+ schema_name: Name of the schema.
+ semantic_model_name: Name of the Semantic Model.
+
+ Returns:
+ A JSON object containing name, comment, definition, properties,
+ and audit information in Gravitino's native REST format.
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_semantic_model_operation().load_semantic_model(
+ catalog_name, schema_name, semantic_model_name
+ )
+
+ @mcp.tool(tags={"semantic_model"})
+ # pylint: disable=too-many-positional-arguments
+ async def create_semantic_model(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ name: str,
+ definition: dict,
+ comment: Optional[str] = None,
+ properties: Optional[dict[str, str]] = None,
+ ) -> str:
+ """Create a schema-scoped Semantic Model.
+
+ Gravitino validates the definition, source access, and authorization.
+
+ Args:
+ ctx: The request context.
+ catalog_name: Name of the catalog.
+ schema_name: Name of the schema.
+ name: Name of the Semantic Model to create.
+ definition: Native REST definition with a non-empty datasets
+ array and optional relationships, metrics, aiContext, and
+ customExtensions. Use camelCase fields. A minimal example:
+ {"datasets": [{"name": "orders", "source": {
+ "namespace": ["sales", "mart"], "name": "orders"
+ }, "fields": []}]}. Source namespaces contain the source
+ catalog and schema within the selected metalake.
+ comment: Optional description.
+ properties: Optional Gravitino properties with string values.
+
+ Returns:
+ The created Semantic Model as a JSON object.
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await
client.as_semantic_model_operation().create_semantic_model(
+ catalog_name, schema_name, name, definition, comment, properties
+ )
+
+ @mcp.tool(tags={"semantic_model"})
+ async def alter_semantic_model(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ semantic_model_name: str,
+ updates: list[dict],
+ ) -> str:
+ """Apply an ordered, non-empty list of updates atomically.
+
+ Args:
+ ctx: The request context.
+ catalog_name: Name of the catalog.
+ schema_name: Name of the schema.
+ semantic_model_name: Current name of the Semantic Model.
+ updates: Native REST operations selected by @type:
+ rename (newName), updateComment (newComment, null to clear),
+ setProperty (property, value), removeProperty (property),
+ or replaceDefinition (definition). Example:
+ [{"@type": "setProperty", "property": "domain",
+ "value": "sales"}]. replaceDefinition supplies the entire
+ definition and revalidates source access.
+
+ Returns:
+ The altered Semantic Model as a JSON object.
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_semantic_model_operation().alter_semantic_model(
+ catalog_name, schema_name, semantic_model_name, updates
+ )
+
+ @mcp.tool(tags={"semantic_model"})
+ async def drop_semantic_model(
+ ctx: Context,
+ catalog_name: str,
+ schema_name: str,
+ semantic_model_name: str,
+ ) -> str:
+ """Drop a Semantic Model's metadata without deleting source data.
+
+ Args:
+ ctx: The request context.
+ catalog_name: Name of the catalog.
+ schema_name: Name of the schema.
+ semantic_model_name: Name of the Semantic Model to drop.
+
+ Returns:
+ A JSON boolean indicating whether the Semantic Model was dropped.
+ """
+ client = ctx.request_context.lifespan_context.rest_client()
+ return await client.as_semantic_model_operation().drop_semantic_model(
+ catalog_name, schema_name, semantic_model_name
+ )
diff --git a/mcp-server/tests/unit/client/test_url_encoding.py
b/mcp-server/tests/unit/client/test_url_encoding.py
index e4450b9f01..4251c24888 100644
--- a/mcp-server/tests/unit/client/test_url_encoding.py
+++ b/mcp-server/tests/unit/client/test_url_encoding.py
@@ -22,6 +22,8 @@ import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock
+import httpx
+
from mcp_server.client.plain.plain_rest_client_catalog_operation import (
PlainRESTClientCatalogOperation,
)
@@ -61,6 +63,7 @@ from
mcp_server.client.plain.plain_rest_client_topic_operation import (
from mcp_server.client.plain.plain_rest_client_view_operation import (
PlainRESTClientViewOperation,
)
+from mcp_server.client.plain.utils import encode_path_segment
def _make_mock_client(response_json: dict):
@@ -99,6 +102,61 @@ _ENCODED_SLASH = "cat%2Fschema"
METALAKE = "my_metalake"
+class TestEncodePathSegment(unittest.TestCase):
+ def test_rejects_empty_and_dot_segments(self):
+ for name in ("", ".", ".."):
+ with self.subTest(name=name):
+ with self.assertRaisesRegex(ValueError, "REST path names"):
+ encode_path_segment(name)
+
+ def test_preserves_encoding_and_string_conversion(self):
+ for value, expected in (
+ (_PATH_TRAVERSAL, _ENCODED_PATH_TRAVERSAL),
+ (_QUERY_INJECTION, _ENCODED_QUERY_INJECTION),
+ (_SLASH, _ENCODED_SLASH),
+ ("sales.v2", "sales.v2"),
+ ("%2e%2e", "%252e%252e"),
+ (1, "1"),
+ ):
+ with self.subTest(value=value):
+ self.assertEqual(encode_path_segment(value), expected)
+
+
+class TestPathNameValidation(unittest.IsolatedAsyncioTestCase):
+ """Invalid names must fail before httpx can normalize the request path."""
+
+ async def asyncSetUp(self):
+ self.requests = []
+
+ def respond(request):
+ self.requests.append(request)
+ return httpx.Response(
+ 200, json={"code": 0, "dropped": True, "identifiers": []}
+ )
+
+ self.client = httpx.AsyncClient(
+ base_url="http://localhost:8090",
+ transport=httpx.MockTransport(respond),
+ )
+ self.addAsyncCleanup(self.client.aclose)
+
+ async def test_drop_table_rejects_invalid_names_before_request(self):
+ op = PlainRESTClientTableOperation(METALAKE, self.client)
+ for name in ("", ".", ".."):
+ with self.subTest(name=name):
+ with self.assertRaisesRegex(ValueError, "REST path names"):
+ await op.drop_table("catalog", "schema", name, False)
+ self.assertEqual(self.requests, [])
+
+ async def test_load_model_rejects_invalid_names_before_request(self):
+ op = PlainRESTClientModelOperation(METALAKE, self.client)
+ for name in ("", ".", ".."):
+ with self.subTest(name=name):
+ with self.assertRaisesRegex(ValueError, "REST path names"):
+ await op.load_model("catalog", "schema", name)
+ self.assertEqual(self.requests, [])
+
+
class TestMetalakeOperation(unittest.TestCase):
"""The one operation that is not scoped to a metalake.
diff --git a/mcp-server/tests/unit/tools/mock_operation.py
b/mcp-server/tests/unit/tools/mock_operation.py
index 371e125509..48737da652 100644
--- a/mcp-server/tests/unit/tools/mock_operation.py
+++ b/mcp-server/tests/unit/tools/mock_operation.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+from typing import Optional
+
from mcp_server.client import (
CatalogOperation,
GravitinoOperation,
@@ -29,6 +31,7 @@ from mcp_server.client.fileset_operation import
FilesetOperation
from mcp_server.client.job_operation import JobOperation
from mcp_server.client.metalake_operation import MetalakeOperation
from mcp_server.client.partition_operation import PartitionOperation
+from mcp_server.client.semantic_model_operation import SemanticModelOperation
from mcp_server.client.statistic_operation import StatisticOperation
from mcp_server.client.view_operation import ViewOperation
@@ -73,6 +76,9 @@ class MockOperation(GravitinoOperation):
def as_view_operation(self) -> ViewOperation:
return MockViewOperation()
+ def as_semantic_model_operation(self) -> SemanticModelOperation:
+ return MockSemanticModelOperation()
+
def as_metalake_operation(self) -> MetalakeOperation:
return MockMetalakeOperation()
@@ -483,3 +489,41 @@ class MockViewOperation(ViewOperation):
self, catalog_name: str, schema_name: str, view_name: str
) -> str:
return f"mock_view: {catalog_name}, {schema_name}, {view_name}"
+
+
+class MockSemanticModelOperation(SemanticModelOperation):
+ async def list_of_semantic_models(
+ self, catalog_name: str, schema_name: str
+ ) -> str:
+ return "[]"
+
+ async def load_semantic_model(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ return '{"name": "mock_semantic_model"}'
+
+ # pylint: disable=too-many-positional-arguments
+ async def create_semantic_model(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ name: str,
+ definition: dict,
+ comment: Optional[str] = None,
+ properties: Optional[dict[str, str]] = None,
+ ) -> str:
+ return '{"name": "mock_semantic_model"}'
+
+ async def alter_semantic_model(
+ self,
+ catalog_name: str,
+ schema_name: str,
+ semantic_model_name: str,
+ updates: list[dict],
+ ) -> str:
+ return '{"name": "mock_semantic_model"}'
+
+ async def drop_semantic_model(
+ self, catalog_name: str, schema_name: str, semantic_model_name: str
+ ) -> str:
+ return "true"
diff --git a/mcp-server/tests/unit/tools/test_semantic_model.py
b/mcp-server/tests/unit/tools/test_semantic_model.py
new file mode 100644
index 0000000000..0f121fcab9
--- /dev/null
+++ b/mcp-server/tests/unit/tools/test_semantic_model.py
@@ -0,0 +1,424 @@
+# 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.
+
+"""Semantic Model tools exercised through FastMCP with REST and stub
clients."""
+
+import json
+import unittest
+from unittest.mock import patch
+
+import httpx
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+
+from mcp_server.client.factory import RESTClientFactory
+from mcp_server.client.plain.plain_rest_client_operation import (
+ PlainRESTClientOperation,
+)
+from mcp_server.core import Setting
+from mcp_server.server import GravitinoMCPServer
+from tests.unit.tools import MockOperation
+
+_NAMESPACE = {"catalog_name": "sales", "schema_name": "mart"}
+_MODEL_ARGS = {**_NAMESPACE, "semantic_model_name": "sales_model"}
+_MODELS_PATH = (
+ "/api/metalakes/test_metalake/catalogs/sales/schemas/mart/semantic-models"
+)
+
+
+class TestSemanticModelTool(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.definition = {
+ "aiContext": {"instructions": "Use certified sales definitions"},
+ "datasets": [
+ {
+ "name": "orders",
+ "source": {
+ "namespace": ["sales", "mart"],
+ "name": "orders",
+ },
+ "primaryKey": ["order_id"],
+ "fields": [],
+ }
+ ],
+ "relationships": [],
+ "metrics": [],
+ "customExtensions": [{"vendorName": "example", "data": "{}"}],
+ }
+ self.model = {
+ "name": "sales_model",
+ "comment": "Sales definitions",
+ "definition": self.definition,
+ "properties": {"domain": "sales"},
+ "audit": {"creator": "admin", "createTime":
"2026-10-13T00:00:00Z"},
+ }
+ self.response = {"code": 0, "semanticModel": self.model}
+ self.status = 200
+ self.requests = []
+ self.http_clients = []
+ factory_patch = patch.object(
+ RESTClientFactory, "_rest_client_class", self._create_client
+ )
+ factory_patch.start()
+ self.addCleanup(factory_patch.stop)
+ self.mcp = GravitinoMCPServer(
+ Setting("test_metalake", token="semantic-test-token")
+ ).mcp
+
+ async def asyncTearDown(self):
+ for client in self.http_clients:
+ await client.aclose()
+
+ def _respond(self, request):
+ self.requests.append(request)
+ return httpx.Response(self.status, json=self.response)
+
+ def _create_client(self, *args, **kwargs):
+ # Keep the production factory/client wiring and inject only transport.
+ async_client_type = httpx.AsyncClient
+
+ def create_http_client(*client_args, **client_kwargs):
+ client = async_client_type(
+ *client_args,
+ transport=httpx.MockTransport(self._respond),
+ **client_kwargs,
+ )
+ self.http_clients.append(client)
+ return client
+
+ with patch(
+
"mcp_server.client.plain.plain_rest_client_operation.httpx.AsyncClient",
+ side_effect=create_http_client,
+ ):
+ return PlainRESTClientOperation(*args, **kwargs)
+
+ async def _call(self, name, arguments):
+ async with Client(self.mcp) as client:
+ result = await client.call_tool(name, arguments)
+ return json.loads(result.content[0].text)
+
+ def _assert_request(self, method, path, body=None):
+ request = self.requests[-1]
+ self.assertEqual(request.method, method)
+ self.assertEqual(request.url.raw_path.decode(), path)
+ self.assertEqual(
+ request.headers["Authorization"], "Bearer semantic-test-token"
+ )
+ if body is None:
+ self.assertEqual(request.content, b"")
+ else:
+ self.assertEqual(json.loads(request.content), body)
+
+ async def test_list_semantic_models(self):
+ for identifiers in (
+ [
+ {
+ "namespace": ["test_metalake", "sales", "mart"],
+ "name": "sales_model",
+ }
+ ],
+ [],
+ ):
+ with self.subTest(identifiers=identifiers):
+ self.response = {"code": 0, "identifiers": identifiers}
+ result = await self._call("list_of_semantic_models",
_NAMESPACE)
+ self.assertEqual(result, identifiers)
+ self._assert_request("GET", _MODELS_PATH)
+
+ async def test_load_semantic_model_preserves_native_definition(self):
+ result = await self._call("load_semantic_model", _MODEL_ARGS)
+ self.assertEqual(result, self.model)
+ self._assert_request("GET", f"{_MODELS_PATH}/sales_model")
+
+ async def test_create_semantic_model_optional_fields(self):
+ required = {"name": "sales_model", "definition": self.definition}
+ for optional, expected in (
+ ({}, {}),
+ ({"comment": None, "properties": None}, {}),
+ (
+ {"comment": "", "properties": {}},
+ {"comment": "", "properties": {}},
+ ),
+ (
+ {
+ "comment": "Sales definitions",
+ "properties": {"domain": "sales"},
+ },
+ {
+ "comment": "Sales definitions",
+ "properties": {"domain": "sales"},
+ },
+ ),
+ ):
+ with self.subTest(optional=optional):
+ result = await self._call(
+ "create_semantic_model",
+ {**_NAMESPACE, **required, **optional},
+ )
+ self.assertEqual(result, self.model)
+ self._assert_request(
+ "POST",
+ _MODELS_PATH,
+ {**required, **expected},
+ )
+
+ async def test_alter_semantic_model_preserves_order_and_null_comment(self):
+ updates = [
+ {"@type": "rename", "newName": "sales_model_v2"},
+ {"@type": "updateComment", "newComment": None},
+ {"@type": "setProperty", "property": "domain", "value": "sales"},
+ {"@type": "removeProperty", "property": "legacy"},
+ {"@type": "replaceDefinition", "definition": self.definition},
+ ]
+ self.model["name"] = "sales_model_v2"
+ self.model["comment"] = None
+ result = await self._call(
+ "alter_semantic_model", {**_MODEL_ARGS, "updates": updates}
+ )
+ self.assertEqual(result, self.model)
+ self._assert_request(
+ "PUT", f"{_MODELS_PATH}/sales_model", {"updates": updates}
+ )
+
+ async def test_drop_semantic_model(self):
+ for dropped in (True, False):
+ with self.subTest(dropped=dropped):
+ self.response = {"code": 0, "dropped": dropped}
+ result = await self._call("drop_semantic_model", _MODEL_ARGS)
+ self.assertIs(result, dropped)
+ self._assert_request("DELETE", f"{_MODELS_PATH}/sales_model")
+
+ async def test_all_operations_encode_each_path_segment(self):
+ namespace = {
+ "metalake": "ml/a",
+ "catalog_name": "cat?",
+ "schema_name": "sch#",
+ }
+ model_name = "sales %"
+ models_path =
"/api/metalakes/ml%2Fa/catalogs/cat%3F/schemas/sch%23/semantic-models"
+ for tool, method, extra, suffix in (
+ ("list_of_semantic_models", "GET", {}, ""),
+ (
+ "create_semantic_model",
+ "POST",
+ {"name": model_name, "definition": self.definition},
+ "",
+ ),
+ (
+ "load_semantic_model",
+ "GET",
+ {"semantic_model_name": model_name},
+ "/sales%20%25",
+ ),
+ (
+ "alter_semantic_model",
+ "PUT",
+ {
+ "semantic_model_name": model_name,
+ "updates": [
+ {"@type": "updateComment", "newComment": "new"}
+ ],
+ },
+ "/sales%20%25",
+ ),
+ (
+ "drop_semantic_model",
+ "DELETE",
+ {"semantic_model_name": model_name},
+ "/sales%20%25",
+ ),
+ ):
+ with self.subTest(tool=tool):
+ await self._call(tool, {**namespace, **extra})
+ request = self.requests[-1]
+ self.assertEqual(request.method, method)
+ self.assertEqual(
+ request.url.raw_path.decode(), models_path + suffix
+ )
+ if tool == "create_semantic_model":
+ self.assertEqual(
+ json.loads(request.content)["name"], model_name
+ )
+
+ async def test_service_and_request_identity_and_metalake(self):
+ async with Client(self.mcp) as client:
+ with patch(
+ "mcp_server.core.context._get_request_authorization",
+ return_value="Bearer request-test-token",
+ ):
+ await client.call_tool(
+ "load_semantic_model",
+ {**_MODEL_ARGS, "metalake": "other_ml"},
+ )
+ await client.call_tool("load_semantic_model", _MODEL_ARGS)
+
+ self.assertEqual(
+ self.requests[0].url.path,
+ _MODELS_PATH.replace("test_metalake", "other_ml") + "/sales_model",
+ )
+ self.assertEqual(
+ self.requests[0].headers["Authorization"],
+ "Bearer request-test-token",
+ )
+ self._assert_request("GET", f"{_MODELS_PATH}/sales_model")
+
+ async def test_ambiguous_path_names_never_send_a_request(self):
+ for argument in (
+ "metalake",
+ "catalog_name",
+ "schema_name",
+ "semantic_model_name",
+ ):
+ # An empty per-call metalake selects the configured default.
+ invalid_names = (
+ (".", "..") if argument == "metalake" else ("", ".", "..")
+ )
+ for name in invalid_names:
+ with self.subTest(argument=argument, name=name):
+ with self.assertRaisesRegex(ToolError, "REST path names"):
+ await self._call(
+ "drop_semantic_model",
+ {**_MODEL_ARGS, argument: name},
+ )
+ self.assertEqual(self.requests, [])
+
+ async def test_dots_within_names_are_preserved(self):
+ await self._call(
+ "load_semantic_model",
+ {**_MODEL_ARGS, "semantic_model_name": "sales.v2"},
+ )
+ self._assert_request("GET", f"{_MODELS_PATH}/sales.v2")
+
+ async def test_rest_errors_reach_the_mcp_caller(self):
+ for tool, arguments, status, error_type in (
+ (
+ "list_of_semantic_models",
+ _NAMESPACE,
+ 403,
+ "ForbiddenException",
+ ),
+ (
+ "load_semantic_model",
+ _MODEL_ARGS,
+ 404,
+ "NoSuchSemanticModelException",
+ ),
+ (
+ "create_semantic_model",
+ {
+ **_NAMESPACE,
+ "name": "sales_model",
+ "definition": self.definition,
+ },
+ 409,
+ "SemanticModelAlreadyExistsException",
+ ),
+ (
+ "alter_semantic_model",
+ {**_MODEL_ARGS, "updates": []},
+ 400,
+ "IllegalArgumentException",
+ ),
+ ("drop_semantic_model", _MODEL_ARGS, 409, "NotInUseException"),
+ ("drop_semantic_model", _MODEL_ARGS, 403, "ForbiddenException"),
+ ):
+ with self.subTest(tool=tool, status=status):
+ self.status = status
+ self.response = {
+ "code": status,
+ "type": error_type,
+ "message": "Semantic Model operation rejected",
+ }
+ with self.assertRaisesRegex(ToolError, error_type):
+ await self._call(tool, arguments)
+
+ async def test_create_requires_definition_before_sending_request(self):
+ with self.assertRaisesRegex(ToolError, "definition"):
+ await self._call(
+ "create_semantic_model",
+ {**_NAMESPACE, "name": "sales_model"},
+ )
+ self.assertEqual(self.requests, [])
+
+ async def test_semantic_model_tag_exposes_all_five_tools(self):
+ self.mcp = GravitinoMCPServer(
+ Setting("test_metalake", tags={"semantic_model"})
+ ).mcp
+ async with Client(self.mcp) as client:
+ tools = {tool.name: tool for tool in await client.list_tools()}
+ self.assertEqual(
+ set(tools),
+ {
+ "list_of_semantic_models",
+ "load_semantic_model",
+ "create_semantic_model",
+ "alter_semantic_model",
+ "drop_semantic_model",
+ },
+ )
+ for tool in tools.values():
+ self.assertIn("metalake", tool.inputSchema["properties"])
+ self.assertEqual(
+ set(tools["create_semantic_model"].inputSchema["required"]),
+ {"catalog_name", "schema_name", "name", "definition"},
+ )
+ self.assertEqual(
+ set(tools["drop_semantic_model"].inputSchema["required"]),
+ {"catalog_name", "schema_name", "semantic_model_name"},
+ )
+
+
+class TestSemanticModelToolWithMockOperation(unittest.IsolatedAsyncioTestCase):
+ async def test_all_tools_return_json_from_mock_operation(self):
+ model_args = {
+ **_NAMESPACE,
+ "semantic_model_name": "mock_semantic_model",
+ }
+ model = {"name": "mock_semantic_model"}
+ cases = (
+ ("list_of_semantic_models", _NAMESPACE, []),
+ ("load_semantic_model", model_args, model),
+ (
+ "create_semantic_model",
+ {**_NAMESPACE, "name": "mock_semantic_model", "definition":
{}},
+ model,
+ ),
+ (
+ "alter_semantic_model",
+ {
+ **model_args,
+ "updates": [
+ {"@type": "updateComment", "newComment": "Updated"}
+ ],
+ },
+ model,
+ ),
+ ("drop_semantic_model", model_args, True),
+ )
+ with patch.object(
+ RESTClientFactory, "_rest_client_class", MockOperation
+ ):
+ mcp = GravitinoMCPServer(
+ Setting("mock_metalake", tags={"semantic_model"})
+ ).mcp
+ async with Client(mcp) as client:
+ for tool, arguments, expected in cases:
+ with self.subTest(tool=tool):
+ result = await client.call_tool(tool, arguments)
+ self.assertEqual(
+ json.loads(result.content[0].text), expected
+ )