Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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