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
+                        )

Reply via email to