Skip to content

Commit 4fe4171

Browse files
authored
Merge branch 'main' into fix-get-ca-bundle
2 parents d43bfbc + 6204737 commit 4fe4171

24 files changed

+633
-111
lines changed

tests/model_registry/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ocp_resources.persistent_volume_claim import PersistentVolumeClaim
1515
from ocp_resources.pod import Pod
1616
from ocp_resources.resource import ResourceEditor
17+
from ocp_resources.route import Route
1718
from ocp_resources.secret import Secret
1819
from ocp_resources.service import Service
1920
from ocp_resources.service_account import ServiceAccount
@@ -458,3 +459,10 @@ def service_account(admin_client: DynamicClient, sa_namespace: Namespace) -> Gen
458459
LOGGER.info(f"Creating ServiceAccount: {sa_name} in namespace {sa_namespace.name}")
459460
with ServiceAccount(client=admin_client, name=sa_name, namespace=sa_namespace.name, wait_for_resource=True) as sa:
460461
yield sa
462+
463+
464+
@pytest.fixture(scope="class")
465+
def model_catalog_routes(admin_client: DynamicClient, model_registry_namespace: str) -> list[Route]:
466+
return list(
467+
Route.get(namespace=model_registry_namespace, label_selector="component=model-catalog", client=admin_client)
468+
)

tests/model_registry/mcp_servers/__init__.py

Whitespace-only changes.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from collections.abc import Generator
2+
3+
import pytest
4+
import requests
5+
import yaml
6+
from kubernetes.dynamic import DynamicClient
7+
from kubernetes.dynamic.exceptions import ResourceNotFoundError
8+
from ocp_resources.config_map import ConfigMap
9+
from ocp_resources.resource import ResourceEditor
10+
from ocp_resources.route import Route
11+
from simple_logger.logger import get_logger
12+
from timeout_sampler import retry
13+
14+
from tests.model_registry.constants import DEFAULT_CUSTOM_MODEL_CATALOG
15+
from tests.model_registry.mcp_servers.constants import (
16+
MCP_CATALOG_API_PATH,
17+
MCP_CATALOG_INVALID_SOURCE,
18+
MCP_CATALOG_SOURCE,
19+
MCP_CATALOG_SOURCE2,
20+
MCP_SERVERS_YAML,
21+
MCP_SERVERS_YAML2,
22+
)
23+
from tests.model_registry.utils import (
24+
TransientUnauthorizedError,
25+
execute_get_call,
26+
execute_get_command,
27+
wait_for_model_catalog_pod_ready_after_deletion,
28+
)
29+
30+
LOGGER = get_logger(name=__name__)
31+
32+
33+
@pytest.fixture(scope="class")
34+
def mcp_catalog_rest_urls(model_registry_namespace: str, model_catalog_routes: list[Route]) -> list[str]:
35+
"""Build MCP catalog REST URL from existing model catalog routes."""
36+
assert model_catalog_routes, f"Model catalog routes do not exist in {model_registry_namespace}"
37+
return [f"https://{route.instance.spec.host}:443{MCP_CATALOG_API_PATH}" for route in model_catalog_routes]
38+
39+
40+
@retry(
41+
wait_timeout=90,
42+
sleep=5,
43+
exceptions_dict={ResourceNotFoundError: [], TransientUnauthorizedError: []},
44+
)
45+
def wait_for_mcp_catalog_api(url: str, headers: dict[str, str]) -> requests.Response:
46+
"""Wait for MCP catalog API to be ready and returning MCP server data."""
47+
LOGGER.info(f"Waiting for MCP catalog API at {url}mcp_servers")
48+
response = execute_get_call(url=f"{url}mcp_servers", headers=headers)
49+
data = response.json()
50+
if not data.get("items"):
51+
raise ResourceNotFoundError("MCP catalog API returned empty items, catalog data not yet loaded")
52+
return response
53+
54+
55+
@pytest.fixture(scope="class")
56+
def mcp_servers_response(
57+
mcp_catalog_rest_urls: list[str],
58+
model_registry_rest_headers: dict[str, str],
59+
) -> dict:
60+
"""Class-scoped fixture that fetches the MCP servers list once per test class."""
61+
return execute_get_command(
62+
url=f"{mcp_catalog_rest_urls[0]}mcp_servers",
63+
headers=model_registry_rest_headers,
64+
)
65+
66+
67+
@pytest.fixture(scope="class")
68+
def mcp_servers_configmap_patch(
69+
admin_client: DynamicClient,
70+
model_registry_namespace: str,
71+
mcp_catalog_rest_urls: list[str],
72+
model_registry_rest_headers: dict[str, str],
73+
) -> Generator[None]:
74+
"""
75+
Class-scoped fixture that patches the model-catalog-sources ConfigMap
76+
77+
Sets two keys in the ConfigMap data:
78+
- sources.yaml: catalog source definition pointing to the MCP servers YAML
79+
- mcp-servers.yaml: the actual MCP server definitions
80+
"""
81+
catalog_config_map = ConfigMap(
82+
name=DEFAULT_CUSTOM_MODEL_CATALOG,
83+
client=admin_client,
84+
namespace=model_registry_namespace,
85+
)
86+
87+
current_data = yaml.safe_load(catalog_config_map.instance.data.get("sources.yaml", "{}") or "{}")
88+
if "mcp_catalogs" not in current_data:
89+
current_data["mcp_catalogs"] = []
90+
current_data["mcp_catalogs"].append(MCP_CATALOG_SOURCE)
91+
92+
patches = {
93+
"data": {
94+
"sources.yaml": yaml.dump(current_data, default_flow_style=False),
95+
"mcp-servers.yaml": MCP_SERVERS_YAML,
96+
}
97+
}
98+
99+
with ResourceEditor(patches={catalog_config_map: patches}):
100+
wait_for_model_catalog_pod_ready_after_deletion(
101+
client=admin_client, model_registry_namespace=model_registry_namespace
102+
)
103+
wait_for_mcp_catalog_api(url=mcp_catalog_rest_urls[0], headers=model_registry_rest_headers)
104+
yield
105+
106+
wait_for_model_catalog_pod_ready_after_deletion(
107+
client=admin_client, model_registry_namespace=model_registry_namespace
108+
)
109+
110+
111+
@pytest.fixture(scope="class")
112+
def mcp_multi_source_configmap_patch(
113+
admin_client: DynamicClient,
114+
model_registry_namespace: str,
115+
mcp_catalog_rest_urls: list[str],
116+
model_registry_rest_headers: dict[str, str],
117+
) -> Generator[None]:
118+
"""
119+
Class-scoped fixture that patches the model-catalog-sources ConfigMap
120+
with two MCP catalog sources pointing to two different YAML files.
121+
"""
122+
catalog_config_map = ConfigMap(
123+
name=DEFAULT_CUSTOM_MODEL_CATALOG,
124+
client=admin_client,
125+
namespace=model_registry_namespace,
126+
)
127+
128+
current_data = yaml.safe_load(catalog_config_map.instance.data.get("sources.yaml", "{}") or "{}")
129+
if "mcp_catalogs" not in current_data:
130+
current_data["mcp_catalogs"] = []
131+
current_data["mcp_catalogs"].extend([MCP_CATALOG_SOURCE, MCP_CATALOG_SOURCE2])
132+
133+
patches = {
134+
"data": {
135+
"sources.yaml": yaml.dump(current_data, default_flow_style=False),
136+
"mcp-servers.yaml": MCP_SERVERS_YAML,
137+
"mcp-servers-2.yaml": MCP_SERVERS_YAML2,
138+
}
139+
}
140+
141+
with ResourceEditor(patches={catalog_config_map: patches}):
142+
wait_for_model_catalog_pod_ready_after_deletion(
143+
client=admin_client, model_registry_namespace=model_registry_namespace
144+
)
145+
wait_for_mcp_catalog_api(url=mcp_catalog_rest_urls[0], headers=model_registry_rest_headers)
146+
yield
147+
148+
wait_for_model_catalog_pod_ready_after_deletion(
149+
client=admin_client, model_registry_namespace=model_registry_namespace
150+
)
151+
152+
153+
@pytest.fixture(scope="class")
154+
def mcp_invalid_yaml_configmap_patch(
155+
request: pytest.FixtureRequest,
156+
admin_client: DynamicClient,
157+
model_registry_namespace: str,
158+
mcp_catalog_rest_urls: list[str],
159+
model_registry_rest_headers: dict[str, str],
160+
) -> Generator[None]:
161+
"""
162+
Class-scoped fixture that patches the ConfigMap with a valid MCP source
163+
plus an invalid one (parameterized via request.param as the invalid YAML content).
164+
"""
165+
catalog_config_map = ConfigMap(
166+
name=DEFAULT_CUSTOM_MODEL_CATALOG,
167+
client=admin_client,
168+
namespace=model_registry_namespace,
169+
)
170+
171+
current_data = yaml.safe_load(catalog_config_map.instance.data.get("sources.yaml", "{}") or "{}")
172+
if "mcp_catalogs" not in current_data:
173+
current_data["mcp_catalogs"] = []
174+
current_data["mcp_catalogs"].extend([MCP_CATALOG_SOURCE, MCP_CATALOG_INVALID_SOURCE])
175+
176+
patches = {
177+
"data": {
178+
"sources.yaml": yaml.dump(current_data, default_flow_style=False),
179+
"mcp-servers.yaml": MCP_SERVERS_YAML,
180+
"mcp-servers-invalid.yaml": request.param,
181+
}
182+
}
183+
184+
with ResourceEditor(patches={catalog_config_map: patches}):
185+
wait_for_model_catalog_pod_ready_after_deletion(
186+
client=admin_client, model_registry_namespace=model_registry_namespace
187+
)
188+
wait_for_mcp_catalog_api(url=mcp_catalog_rest_urls[0], headers=model_registry_rest_headers)
189+
yield
190+
191+
wait_for_model_catalog_pod_ready_after_deletion(
192+
client=admin_client, model_registry_namespace=model_registry_namespace
193+
)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
MCP_CATALOG_SOURCE_ID: str = "test_mcp_servers"
2+
MCP_CATALOG_SOURCE_NAME: str = "Test MCP Servers"
3+
MCP_CATALOG_API_PATH: str = "/api/mcp_catalog/v1alpha1/"
4+
MCP_SERVERS_YAML_CATALOG_PATH: str = "/data/user-sources/mcp-servers.yaml"
5+
6+
MCP_SERVERS_YAML: str = """\
7+
mcp_servers:
8+
- name: weather-api
9+
description: "Community weather API MCP server"
10+
provider: "Weather Community"
11+
version: "1.0.0"
12+
license: "MIT"
13+
tags:
14+
- weather
15+
- api
16+
- community
17+
tools:
18+
- name: get_current_weather
19+
description: "Get current weather for a location"
20+
- name: get_forecast
21+
description: "Get weather forecast"
22+
createTimeSinceEpoch: "1736510400000"
23+
lastUpdateTimeSinceEpoch: "1736510400000"
24+
25+
- name: file-manager
26+
description: "File system management MCP server"
27+
provider: "Community Dev"
28+
version: "0.9.2"
29+
license: "BSD-3-Clause"
30+
tags:
31+
- filesystem
32+
- files
33+
- management
34+
tools:
35+
- name: read_file
36+
description: "Read file contents"
37+
- name: write_file
38+
description: "Write to files"
39+
- name: list_directory
40+
description: "List directory contents"
41+
createTimeSinceEpoch: "1738300800000"
42+
lastUpdateTimeSinceEpoch: "1739510400000"
43+
44+
- name: calculator
45+
description: "Mathematical calculator MCP server"
46+
provider: "Math Community"
47+
version: "2.0.0"
48+
license: "MIT"
49+
tags:
50+
- math
51+
- calculator
52+
- computation
53+
customProperties:
54+
verifiedSource:
55+
metadataType: MetadataBoolValue
56+
bool_value: true
57+
sast:
58+
metadataType: MetadataBoolValue
59+
bool_value: true
60+
readOnlyTools:
61+
metadataType: MetadataBoolValue
62+
bool_value: true
63+
observability:
64+
metadataType: MetadataStringValue
65+
string_value: ""
66+
tools:
67+
- name: calculate
68+
description: "Perform mathematical calculations"
69+
- name: solve_equation
70+
description: "Solve mathematical equations"
71+
createTimeSinceEpoch: "1740091200000"
72+
lastUpdateTimeSinceEpoch: "1740091200000"
73+
"""
74+
75+
MCP_CATALOG_SOURCE: dict = {
76+
"name": MCP_CATALOG_SOURCE_NAME,
77+
"id": MCP_CATALOG_SOURCE_ID,
78+
"type": "yaml",
79+
"enabled": True,
80+
"properties": {"yamlCatalogPath": MCP_SERVERS_YAML_CATALOG_PATH},
81+
"labels": [MCP_CATALOG_SOURCE_NAME],
82+
}
83+
84+
85+
MCP_CATALOG_SOURCE2_ID: str = "test_mcp_servers_2"
86+
MCP_CATALOG_SOURCE2_NAME: str = "Test MCP Servers 2"
87+
MCP_SERVERS_YAML2_CATALOG_PATH: str = "/data/user-sources/mcp-servers-2.yaml"
88+
89+
MCP_SERVERS_YAML2: str = """\
90+
mcp_servers:
91+
- name: code-reviewer
92+
description: "Code review assistant MCP server"
93+
provider: "DevOps Tools"
94+
version: "1.2.0"
95+
license: "Apache-2.0"
96+
tags:
97+
- code
98+
- review
99+
tools:
100+
- name: review_pull_request
101+
description: "Review a pull request"
102+
- name: suggest_fix
103+
description: "Suggest a code fix"
104+
"""
105+
106+
MCP_CATALOG_SOURCE2: dict = {
107+
"name": MCP_CATALOG_SOURCE2_NAME,
108+
"id": MCP_CATALOG_SOURCE2_ID,
109+
"type": "yaml",
110+
"enabled": True,
111+
"properties": {"yamlCatalogPath": MCP_SERVERS_YAML2_CATALOG_PATH},
112+
"labels": [MCP_CATALOG_SOURCE2_NAME],
113+
}
114+
115+
EXPECTED_MCP_SERVER_NAMES: set[str] = {"weather-api", "file-manager", "calculator"}
116+
117+
EXPECTED_MCP_SERVER_TOOL_COUNTS: dict[str, int] = {
118+
"weather-api": 2,
119+
"file-manager": 3,
120+
"calculator": 2,
121+
}
122+
123+
EXPECTED_MCP_SERVER_TOOLS: dict[str, list[str]] = {
124+
"weather-api": ["get_current_weather", "get_forecast"],
125+
"file-manager": ["read_file", "write_file", "list_directory"],
126+
"calculator": ["calculate", "solve_equation"],
127+
}
128+
129+
EXPECTED_MCP_SERVER_TIMESTAMPS: dict[str, dict[str, str]] = {
130+
"weather-api": {"createTimeSinceEpoch": "1736510400000", "lastUpdateTimeSinceEpoch": "1736510400000"},
131+
"file-manager": {"createTimeSinceEpoch": "1738300800000", "lastUpdateTimeSinceEpoch": "1739510400000"},
132+
"calculator": {"createTimeSinceEpoch": "1740091200000", "lastUpdateTimeSinceEpoch": "1740091200000"},
133+
}
134+
135+
MCP_CATALOG_INVALID_SOURCE_ID: str = "test_mcp_servers_invalid"
136+
MCP_CATALOG_INVALID_SOURCE_NAME: str = "Test MCP Servers Invalid"
137+
MCP_SERVERS_YAML_INVALID_CATALOG_PATH: str = "/data/user-sources/mcp-servers-invalid.yaml"
138+
139+
MCP_SERVERS_YAML_MALFORMED: str = """\
140+
mcp_servers:
141+
- name: broken-server
142+
description: "This YAML has a syntax error
143+
version: "1.0.0"
144+
- name: [invalid
145+
"""
146+
147+
MCP_SERVERS_YAML_MISSING_NAME: str = """\
148+
mcp_servers:
149+
- description: "Server without a name field"
150+
provider: "Unnamed Provider"
151+
version: "1.0.0"
152+
tools:
153+
- name: some_tool
154+
description: "A tool on a nameless server"
155+
"""
156+
157+
MCP_CATALOG_INVALID_SOURCE: dict = {
158+
"name": MCP_CATALOG_INVALID_SOURCE_NAME,
159+
"id": MCP_CATALOG_INVALID_SOURCE_ID,
160+
"type": "yaml",
161+
"enabled": True,
162+
"properties": {"yamlCatalogPath": MCP_SERVERS_YAML_INVALID_CATALOG_PATH},
163+
"labels": [MCP_CATALOG_INVALID_SOURCE_NAME],
164+
}
165+
166+
EXPECTED_MCP_SOURCE2_SERVER_NAMES: set[str] = {"code-reviewer"}
167+
EXPECTED_ALL_MCP_SERVER_NAMES: set[str] = EXPECTED_MCP_SERVER_NAMES | EXPECTED_MCP_SOURCE2_SERVER_NAMES
168+
169+
EXPECTED_MCP_SOURCE_ID_MAP: dict[str, str] = {
170+
"weather-api": MCP_CATALOG_SOURCE_ID,
171+
"file-manager": MCP_CATALOG_SOURCE_ID,
172+
"calculator": MCP_CATALOG_SOURCE_ID,
173+
"code-reviewer": MCP_CATALOG_SOURCE2_ID,
174+
}

0 commit comments

Comments
 (0)