Skip to content

Commit fa98cd0

Browse files
fix: escape find_experiment_in_project pattern as literal CI regex (#6)
1 parent 3f69577 commit fa98cd0

2 files changed

Lines changed: 67 additions & 5 deletions

File tree

src/clearml_mcp/clearml_mcp.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ def initialize_clearml_connection() -> None:
2626
_TASK_FIELDS = ("id", "name", "status", "type", "comment", "created", "project", "tags")
2727

2828

29+
def _literal_ci_regex(query: str) -> str:
30+
"""Build a case-insensitive regex that matches ``query`` as a literal substring.
31+
32+
ClearML matches ``task_name`` and ``_any_`` patterns as regular expressions
33+
server-side, so user input must be escaped to match literally (e.g. ``a.b``
34+
matches the literal string, ``[`` does not error the query) and prefixed with
35+
``(?i)`` to preserve the case-insensitive behaviour callers expect.
36+
"""
37+
return f"(?i){re.escape(query)}"
38+
39+
2940
def _project_id_to_name(task_dicts: list[dict[str, Any]]) -> dict[str, str]:
3041
"""Build a project-id -> project-name map for the projects referenced by tasks.
3142
@@ -311,8 +322,11 @@ async def find_experiment_in_project(
311322
) -> list[dict[str, Any]]:
312323
"""Find experiments in a specific project by name pattern."""
313324
try:
314-
# task_name matching happens server-side, so only matching tasks come back.
315-
tasks = _query_task_dicts(project_name=project_name, task_name=experiment_pattern)
325+
# Match the pattern as a literal, case-insensitive substring server-side
326+
# (ClearML treats task_name as a regex), so only matching tasks come back.
327+
tasks = _query_task_dicts(
328+
project_name=project_name, task_name=_literal_ci_regex(experiment_pattern)
329+
)
316330
return [
317331
{
318332
"id": t["id"],
@@ -412,8 +426,7 @@ async def search_tasks(query: str, project_name: str | None = None) -> list[dict
412426
try:
413427
# Match the query as a literal substring (case-insensitive) against
414428
# name/comment/tags server-side, so we never hydrate non-matching tasks.
415-
pattern = f"(?i){re.escape(query)}"
416-
tasks = _query_task_dicts(project_name=project_name, any_pattern=pattern)
429+
tasks = _query_task_dicts(project_name=project_name, any_pattern=_literal_ci_regex(query))
417430
return [
418431
{
419432
"id": t["id"],

tests/test_clearml_mcp.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,10 +721,59 @@ async def test_find_experiment_in_project_returns_matching_experiments(self, moc
721721
assert result[0]["name"] == "Training Experiment"
722722
assert result[1]["name"] == "Validation Experiment"
723723
# The pattern is matched server-side via task_name, not by hydrating each task.
724+
# It is built as a case-insensitive literal-substring regex.
724725
_, kwargs = mock_task.query_tasks.call_args
725-
assert kwargs["task_name"] == "experiment"
726+
assert kwargs["task_name"] == "(?i)experiment"
726727
mock_task.get_task.assert_not_called()
727728

729+
@pytest.mark.asyncio
730+
@patch("clearml_mcp.clearml_mcp.Task")
731+
async def test_find_experiment_escapes_regex_special_characters(self, mock_task):
732+
"""find_experiment_in_project matches the pattern literally, not as a regex.
733+
734+
ClearML treats task_name as a regex, so metacharacters must be escaped:
735+
"a.b" should match only the literal "a.b" (not "axb"), and "exp[1]" must
736+
not error the query.
737+
"""
738+
mock_task.query_tasks.side_effect = _make_query_tasks([])
739+
mock_task.get_projects.return_value = []
740+
741+
result = await clearml_mcp.find_experiment_in_project.fn("ML Project", "exp[1].a")
742+
743+
assert result == []
744+
_, kwargs = mock_task.query_tasks.call_args
745+
# Special characters are escaped (literal match) and case-insensitive.
746+
assert kwargs["task_name"] == r"(?i)exp\[1\]\.a"
747+
mock_task.get_task.assert_not_called()
748+
749+
@pytest.mark.asyncio
750+
@patch("clearml_mcp.clearml_mcp.Task")
751+
async def test_find_experiment_is_case_insensitive(self, mock_task):
752+
"""find_experiment_in_project preserves case-insensitive matching."""
753+
mock_task.query_tasks.side_effect = _make_query_tasks(
754+
[
755+
{
756+
"id": "task_1",
757+
"name": "Training EXPERIMENT",
758+
"status": "completed",
759+
"type": "training",
760+
"comment": "",
761+
"created": "2024-01-01T00:00:00Z",
762+
"project": "proj_1",
763+
"tags": [],
764+
}
765+
]
766+
)
767+
mock_task.get_projects.return_value = [_fake_project("proj_1", "ML Project")]
768+
769+
result = await clearml_mcp.find_experiment_in_project.fn("ML Project", "experiment")
770+
771+
# The (?i) prefix makes the server match regardless of case.
772+
_, kwargs = mock_task.query_tasks.call_args
773+
assert kwargs["task_name"].startswith("(?i)")
774+
assert len(result) == 1
775+
assert result[0]["name"] == "Training EXPERIMENT"
776+
728777
@pytest.mark.asyncio
729778
@patch("clearml_mcp.clearml_mcp.Task")
730779
async def test_find_experiment_returns_error_on_query_failure(self, mock_task):

0 commit comments

Comments
 (0)