Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions tests/model_registry/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ocp_resources.persistent_volume_claim import PersistentVolumeClaim
from ocp_resources.pod import Pod
from ocp_resources.resource import ResourceEditor
from ocp_resources.route import Route
from ocp_resources.secret import Secret
from ocp_resources.service import Service
from ocp_resources.service_account import ServiceAccount
Expand Down Expand Up @@ -458,3 +459,10 @@ def service_account(admin_client: DynamicClient, sa_namespace: Namespace) -> Gen
LOGGER.info(f"Creating ServiceAccount: {sa_name} in namespace {sa_namespace.name}")
with ServiceAccount(client=admin_client, name=sa_name, namespace=sa_namespace.name, wait_for_resource=True) as sa:
yield sa


@pytest.fixture(scope="class")
def model_catalog_routes(admin_client: DynamicClient, model_registry_namespace: str) -> list[Route]:
return list(
Route.get(namespace=model_registry_namespace, label_selector="component=model-catalog", client=admin_client)
)
Empty file.
193 changes: 193 additions & 0 deletions tests/model_registry/mcp_servers/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
from collections.abc import Generator

import pytest
import requests
import yaml
from kubernetes.dynamic import DynamicClient
from kubernetes.dynamic.exceptions import ResourceNotFoundError
from ocp_resources.config_map import ConfigMap
from ocp_resources.resource import ResourceEditor
from ocp_resources.route import Route
from simple_logger.logger import get_logger
from timeout_sampler import retry

from tests.model_registry.constants import DEFAULT_CUSTOM_MODEL_CATALOG
from tests.model_registry.mcp_servers.constants import (
MCP_CATALOG_API_PATH,
MCP_CATALOG_INVALID_SOURCE,
MCP_CATALOG_SOURCE,
MCP_CATALOG_SOURCE2,
MCP_SERVERS_YAML,
MCP_SERVERS_YAML2,
)
from tests.model_registry.utils import (
TransientUnauthorizedError,
execute_get_call,
execute_get_command,
wait_for_model_catalog_pod_ready_after_deletion,
)

LOGGER = get_logger(name=__name__)


@pytest.fixture(scope="class")
def mcp_catalog_rest_urls(model_registry_namespace: str, model_catalog_routes: list[Route]) -> list[str]:
"""Build MCP catalog REST URL from existing model catalog routes."""
assert model_catalog_routes, f"Model catalog routes do not exist in {model_registry_namespace}"
return [f"https://{route.instance.spec.host}:443{MCP_CATALOG_API_PATH}" for route in model_catalog_routes]


@retry(
wait_timeout=90,
sleep=5,
exceptions_dict={ResourceNotFoundError: [], TransientUnauthorizedError: []},
)
def wait_for_mcp_catalog_api(url: str, headers: dict[str, str]) -> requests.Response:
"""Wait for MCP catalog API to be ready and returning MCP server data."""
LOGGER.info(f"Waiting for MCP catalog API at {url}mcp_servers")
response = execute_get_call(url=f"{url}mcp_servers", headers=headers)
data = response.json()
if not data.get("items"):
raise ResourceNotFoundError("MCP catalog API returned empty items, catalog data not yet loaded")
Comment thread
fege marked this conversation as resolved.
return response
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@pytest.fixture(scope="class")
def mcp_servers_response(
mcp_catalog_rest_urls: list[str],
model_registry_rest_headers: dict[str, str],
) -> dict:
"""Class-scoped fixture that fetches the MCP servers list once per test class."""
return execute_get_command(
url=f"{mcp_catalog_rest_urls[0]}mcp_servers",
headers=model_registry_rest_headers,
)


@pytest.fixture(scope="class")
def mcp_servers_configmap_patch(
admin_client: DynamicClient,
model_registry_namespace: str,
mcp_catalog_rest_urls: list[str],
model_registry_rest_headers: dict[str, str],
) -> Generator[None]:
"""
Class-scoped fixture that patches the model-catalog-sources ConfigMap

Sets two keys in the ConfigMap data:
- sources.yaml: catalog source definition pointing to the MCP servers YAML
- mcp-servers.yaml: the actual MCP server definitions
"""
catalog_config_map = ConfigMap(
name=DEFAULT_CUSTOM_MODEL_CATALOG,
client=admin_client,
namespace=model_registry_namespace,
)

current_data = yaml.safe_load(catalog_config_map.instance.data.get("sources.yaml", "{}") or "{}")
if "mcp_catalogs" not in current_data:
current_data["mcp_catalogs"] = []
current_data["mcp_catalogs"].append(MCP_CATALOG_SOURCE)

patches = {
"data": {
"sources.yaml": yaml.dump(current_data, default_flow_style=False),
Comment thread
dbasunag marked this conversation as resolved.
"mcp-servers.yaml": MCP_SERVERS_YAML,
}
}

with ResourceEditor(patches={catalog_config_map: patches}):
wait_for_model_catalog_pod_ready_after_deletion(
client=admin_client, model_registry_namespace=model_registry_namespace
)
wait_for_mcp_catalog_api(url=mcp_catalog_rest_urls[0], headers=model_registry_rest_headers)
yield

wait_for_model_catalog_pod_ready_after_deletion(
client=admin_client, model_registry_namespace=model_registry_namespace
)


@pytest.fixture(scope="class")
def mcp_multi_source_configmap_patch(
admin_client: DynamicClient,
model_registry_namespace: str,
mcp_catalog_rest_urls: list[str],
model_registry_rest_headers: dict[str, str],
) -> Generator[None]:
"""
Class-scoped fixture that patches the model-catalog-sources ConfigMap
with two MCP catalog sources pointing to two different YAML files.
"""
catalog_config_map = ConfigMap(
name=DEFAULT_CUSTOM_MODEL_CATALOG,
client=admin_client,
namespace=model_registry_namespace,
)

current_data = yaml.safe_load(catalog_config_map.instance.data.get("sources.yaml", "{}") or "{}")
if "mcp_catalogs" not in current_data:
current_data["mcp_catalogs"] = []
current_data["mcp_catalogs"].extend([MCP_CATALOG_SOURCE, MCP_CATALOG_SOURCE2])

patches = {
"data": {
"sources.yaml": yaml.dump(current_data, default_flow_style=False),
"mcp-servers.yaml": MCP_SERVERS_YAML,
"mcp-servers-2.yaml": MCP_SERVERS_YAML2,
Comment thread
dbasunag marked this conversation as resolved.
}
}

with ResourceEditor(patches={catalog_config_map: patches}):
wait_for_model_catalog_pod_ready_after_deletion(
client=admin_client, model_registry_namespace=model_registry_namespace
)
wait_for_mcp_catalog_api(url=mcp_catalog_rest_urls[0], headers=model_registry_rest_headers)
yield

wait_for_model_catalog_pod_ready_after_deletion(
client=admin_client, model_registry_namespace=model_registry_namespace
)


@pytest.fixture(scope="class")
def mcp_invalid_yaml_configmap_patch(
request: pytest.FixtureRequest,
admin_client: DynamicClient,
model_registry_namespace: str,
mcp_catalog_rest_urls: list[str],
model_registry_rest_headers: dict[str, str],
) -> Generator[None]:
"""
Class-scoped fixture that patches the ConfigMap with a valid MCP source
plus an invalid one (parameterized via request.param as the invalid YAML content).
"""
catalog_config_map = ConfigMap(
name=DEFAULT_CUSTOM_MODEL_CATALOG,
client=admin_client,
namespace=model_registry_namespace,
)

current_data = yaml.safe_load(catalog_config_map.instance.data.get("sources.yaml", "{}") or "{}")
if "mcp_catalogs" not in current_data:
current_data["mcp_catalogs"] = []
current_data["mcp_catalogs"].extend([MCP_CATALOG_SOURCE, MCP_CATALOG_INVALID_SOURCE])

patches = {
"data": {
"sources.yaml": yaml.dump(current_data, default_flow_style=False),
"mcp-servers.yaml": MCP_SERVERS_YAML,
"mcp-servers-invalid.yaml": request.param,
}
}

with ResourceEditor(patches={catalog_config_map: patches}):
wait_for_model_catalog_pod_ready_after_deletion(
client=admin_client, model_registry_namespace=model_registry_namespace
)
wait_for_mcp_catalog_api(url=mcp_catalog_rest_urls[0], headers=model_registry_rest_headers)
yield

wait_for_model_catalog_pod_ready_after_deletion(
client=admin_client, model_registry_namespace=model_registry_namespace
)
174 changes: 174 additions & 0 deletions tests/model_registry/mcp_servers/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
MCP_CATALOG_SOURCE_ID: str = "test_mcp_servers"
MCP_CATALOG_SOURCE_NAME: str = "Test MCP Servers"
MCP_CATALOG_API_PATH: str = "/api/mcp_catalog/v1alpha1/"
MCP_SERVERS_YAML_CATALOG_PATH: str = "/data/user-sources/mcp-servers.yaml"

MCP_SERVERS_YAML: str = """\
mcp_servers:
- name: weather-api
description: "Community weather API MCP server"
provider: "Weather Community"
version: "1.0.0"
license: "MIT"
tags:
- weather
- api
- community
tools:
- name: get_current_weather
description: "Get current weather for a location"
- name: get_forecast
description: "Get weather forecast"
createTimeSinceEpoch: "1736510400000"
lastUpdateTimeSinceEpoch: "1736510400000"

- name: file-manager
description: "File system management MCP server"
provider: "Community Dev"
version: "0.9.2"
license: "BSD-3-Clause"
tags:
- filesystem
- files
- management
tools:
- name: read_file
description: "Read file contents"
- name: write_file
description: "Write to files"
- name: list_directory
description: "List directory contents"
createTimeSinceEpoch: "1738300800000"
lastUpdateTimeSinceEpoch: "1739510400000"

- name: calculator
description: "Mathematical calculator MCP server"
provider: "Math Community"
version: "2.0.0"
license: "MIT"
tags:
- math
- calculator
- computation
customProperties:
verifiedSource:
metadataType: MetadataBoolValue
bool_value: true
sast:
metadataType: MetadataBoolValue
bool_value: true
readOnlyTools:
metadataType: MetadataBoolValue
bool_value: true
observability:
metadataType: MetadataStringValue
string_value: ""
tools:
- name: calculate
description: "Perform mathematical calculations"
- name: solve_equation
description: "Solve mathematical equations"
createTimeSinceEpoch: "1740091200000"
lastUpdateTimeSinceEpoch: "1740091200000"
"""

MCP_CATALOG_SOURCE: dict = {
"name": MCP_CATALOG_SOURCE_NAME,
"id": MCP_CATALOG_SOURCE_ID,
"type": "yaml",
"enabled": True,
"properties": {"yamlCatalogPath": MCP_SERVERS_YAML_CATALOG_PATH},
"labels": [MCP_CATALOG_SOURCE_NAME],
}


MCP_CATALOG_SOURCE2_ID: str = "test_mcp_servers_2"
MCP_CATALOG_SOURCE2_NAME: str = "Test MCP Servers 2"
MCP_SERVERS_YAML2_CATALOG_PATH: str = "/data/user-sources/mcp-servers-2.yaml"

MCP_SERVERS_YAML2: str = """\
mcp_servers:
- name: code-reviewer
description: "Code review assistant MCP server"
provider: "DevOps Tools"
version: "1.2.0"
license: "Apache-2.0"
tags:
- code
- review
tools:
- name: review_pull_request
description: "Review a pull request"
- name: suggest_fix
description: "Suggest a code fix"
"""

MCP_CATALOG_SOURCE2: dict = {
"name": MCP_CATALOG_SOURCE2_NAME,
"id": MCP_CATALOG_SOURCE2_ID,
"type": "yaml",
"enabled": True,
"properties": {"yamlCatalogPath": MCP_SERVERS_YAML2_CATALOG_PATH},
"labels": [MCP_CATALOG_SOURCE2_NAME],
}

EXPECTED_MCP_SERVER_NAMES: set[str] = {"weather-api", "file-manager", "calculator"}

EXPECTED_MCP_SERVER_TOOL_COUNTS: dict[str, int] = {
"weather-api": 2,
"file-manager": 3,
"calculator": 2,
}

EXPECTED_MCP_SERVER_TOOLS: dict[str, list[str]] = {
"weather-api": ["get_current_weather", "get_forecast"],
"file-manager": ["read_file", "write_file", "list_directory"],
"calculator": ["calculate", "solve_equation"],
}

EXPECTED_MCP_SERVER_TIMESTAMPS: dict[str, dict[str, str]] = {
"weather-api": {"createTimeSinceEpoch": "1736510400000", "lastUpdateTimeSinceEpoch": "1736510400000"},
"file-manager": {"createTimeSinceEpoch": "1738300800000", "lastUpdateTimeSinceEpoch": "1739510400000"},
"calculator": {"createTimeSinceEpoch": "1740091200000", "lastUpdateTimeSinceEpoch": "1740091200000"},
}

MCP_CATALOG_INVALID_SOURCE_ID: str = "test_mcp_servers_invalid"
MCP_CATALOG_INVALID_SOURCE_NAME: str = "Test MCP Servers Invalid"
MCP_SERVERS_YAML_INVALID_CATALOG_PATH: str = "/data/user-sources/mcp-servers-invalid.yaml"

MCP_SERVERS_YAML_MALFORMED: str = """\
mcp_servers:
- name: broken-server
description: "This YAML has a syntax error
version: "1.0.0"
- name: [invalid
"""

MCP_SERVERS_YAML_MISSING_NAME: str = """\
mcp_servers:
- description: "Server without a name field"
provider: "Unnamed Provider"
version: "1.0.0"
tools:
- name: some_tool
description: "A tool on a nameless server"
"""

MCP_CATALOG_INVALID_SOURCE: dict = {
"name": MCP_CATALOG_INVALID_SOURCE_NAME,
"id": MCP_CATALOG_INVALID_SOURCE_ID,
"type": "yaml",
"enabled": True,
"properties": {"yamlCatalogPath": MCP_SERVERS_YAML_INVALID_CATALOG_PATH},
"labels": [MCP_CATALOG_INVALID_SOURCE_NAME],
}

EXPECTED_MCP_SOURCE2_SERVER_NAMES: set[str] = {"code-reviewer"}
EXPECTED_ALL_MCP_SERVER_NAMES: set[str] = EXPECTED_MCP_SERVER_NAMES | EXPECTED_MCP_SOURCE2_SERVER_NAMES

EXPECTED_MCP_SOURCE_ID_MAP: dict[str, str] = {
"weather-api": MCP_CATALOG_SOURCE_ID,
"file-manager": MCP_CATALOG_SOURCE_ID,
"calculator": MCP_CATALOG_SOURCE_ID,
"code-reviewer": MCP_CATALOG_SOURCE2_ID,
}
Loading