11"""ClearML MCP Server implementation."""
22
3- from typing import Any
3+ import re
4+ from typing import Any , cast
45
56from clearml import Model , Task
67from fastmcp import FastMCP
@@ -18,6 +19,77 @@ def initialize_clearml_connection() -> None:
1819 raise RuntimeError (f"Failed to initialize ClearML connection: { e !s} " )
1920
2021
22+ # Fields fetched in the single bulk ``query_tasks`` call below. Requesting these
23+ # via ``additional_return_fields`` makes ClearML hydrate them server-side in one
24+ # paginated call (``tasks.get_all_ex`` with ``only_fields``), avoiding a per-task
25+ # ``Task.get_task`` round-trip (the N+1 problem fixed here, see issue #6).
26+ _TASK_FIELDS = ("id" , "name" , "status" , "type" , "comment" , "created" , "project" , "tags" )
27+
28+
29+ def _project_id_to_name (task_dicts : list [dict [str , Any ]]) -> dict [str , str ]:
30+ """Build a project-id -> project-name map for the projects referenced by tasks.
31+
32+ ``query_tasks`` returns a project *id* per task. We resolve names in a single
33+ ``get_projects`` call rather than one lookup per task.
34+ """
35+ project_ids = {d .get ("project" ) for d in task_dicts if d .get ("project" )}
36+ if not project_ids :
37+ return {}
38+ return {
39+ proj .id : proj .name
40+ for proj in Task .get_projects ()
41+ if getattr (proj , "id" , None ) in project_ids
42+ }
43+
44+
45+ def _query_task_dicts (
46+ project_name : str | None = None ,
47+ task_name : str | None = None ,
48+ tags : list [str ] | None = None ,
49+ status : str | None = None ,
50+ any_pattern : str | None = None ,
51+ any_fields : tuple [str , ...] = ("name" , "comment" , "tags" ),
52+ ) -> list [dict [str , Any ]]:
53+ """Fetch fully hydrated task records in a single bulk backend call.
54+
55+ ``Task.query_tasks`` returns bare ID strings by default; with
56+ ``additional_return_fields`` it returns a list of dicts with the requested
57+ fields fetched in one bulk call. ``task_name``, ``status`` and ``any_pattern``
58+ are all matched server-side so we never download every task just to filter
59+ client-side. ``any_pattern`` is a regex matched against any of ``any_fields``.
60+ """
61+ task_filter : dict [str , Any ] = {}
62+ if status :
63+ task_filter ["status" ] = [status ]
64+ if any_pattern :
65+ task_filter ["_any_" ] = {"fields" : list (any_fields ), "pattern" : any_pattern }
66+ # ``additional_return_fields`` guarantees a list of dicts (one per task).
67+ raw = cast (
68+ "list[dict[str, Any]]" ,
69+ Task .query_tasks (
70+ project_name = project_name ,
71+ task_name = task_name ,
72+ tags = tags ,
73+ additional_return_fields = list (_TASK_FIELDS ),
74+ task_filter = task_filter or None ,
75+ ),
76+ )
77+ project_names = _project_id_to_name (raw )
78+ return [
79+ {
80+ "id" : d .get ("id" ),
81+ "name" : d .get ("name" ),
82+ "status" : d .get ("status" ),
83+ "type" : d .get ("type" ),
84+ "comment" : d .get ("comment" ) or "" ,
85+ "created" : str (d .get ("created" )) if d .get ("created" ) is not None else None ,
86+ "project" : project_names .get (d .get ("project" ), d .get ("project" )),
87+ "tags" : list (d .get ("tags" ) or []),
88+ }
89+ for d in raw
90+ ]
91+
92+
2193@mcp .tool ()
2294async def get_task_info (task_id : str ) -> dict [str , Any ]:
2395 """Get ClearML task details, parameters, and status."""
@@ -46,33 +118,18 @@ async def list_tasks(
46118) -> list [dict [str , Any ]]:
47119 """List ClearML tasks with filters."""
48120 try :
49- # Task.query_tasks returns task IDs (strings), not task objects
50- task_ids = Task .query_tasks (
51- project_name = project_name ,
52- task_filter = {"status" : [status ]} if status else None ,
53- tags = tags ,
54- )
55-
56- # Convert task IDs to full task objects
57- tasks = []
58- for task_id in task_ids :
59- try :
60- task = Task .get_task (task_id = task_id )
61- tasks .append (
62- {
63- "id" : task .id ,
64- "name" : task .name ,
65- "status" : task .status ,
66- "project" : task .get_project_name (),
67- "created" : str (task .data .created ),
68- "tags" : list (task .data .tags ) if task .data .tags else [],
69- }
70- )
71- except Exception as e :
72- # If we can't get a specific task, include the error but continue
73- tasks .append ({"id" : task_id , "error" : f"Failed to get task details: { e !s} " })
74-
75- return tasks
121+ tasks = _query_task_dicts (project_name = project_name , status = status , tags = tags )
122+ return [
123+ {
124+ "id" : t ["id" ],
125+ "name" : t ["name" ],
126+ "status" : t ["status" ],
127+ "project" : t ["project" ],
128+ "created" : t ["created" ],
129+ "tags" : t ["tags" ],
130+ }
131+ for t in tasks
132+ ]
76133 except Exception as e :
77134 return [{"error" : f"Failed to list tasks: { e !s} " }]
78135
@@ -254,30 +311,18 @@ async def find_experiment_in_project(
254311) -> list [dict [str , Any ]]:
255312 """Find experiments in a specific project by name pattern."""
256313 try :
257- # Get task IDs for the project
258- task_ids = Task .query_tasks (project_name = project_name )
259-
260- matching_experiments = []
261- pattern_lower = experiment_pattern .lower ()
262-
263- for task_id in task_ids :
264- try :
265- task = Task .get_task (task_id = task_id )
266- if pattern_lower in task .name .lower ():
267- matching_experiments .append (
268- {
269- "id" : task .id ,
270- "name" : task .name ,
271- "status" : task .status ,
272- "project" : task .get_project_name (),
273- "created" : str (task .data .created ),
274- }
275- )
276- except Exception :
277- # Skip tasks we can't access - could be permissions or API issues
278- pass
279-
280- return matching_experiments
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 )
316+ return [
317+ {
318+ "id" : t ["id" ],
319+ "name" : t ["name" ],
320+ "status" : t ["status" ],
321+ "project" : t ["project" ],
322+ "created" : t ["created" ],
323+ }
324+ for t in tasks
325+ ]
281326 except Exception as e :
282327 return [{"error" : f"Failed to find experiments: { e !s} " }]
283328
@@ -302,18 +347,19 @@ async def list_projects() -> list[dict[str, Any]]:
302347async def get_project_stats (project_name : str ) -> dict [str , Any ]:
303348 """Get project statistics and task counts."""
304349 try :
305- tasks = Task . query_tasks (project_name = project_name )
350+ tasks = _query_task_dicts (project_name = project_name )
306351
307- status_counts = {}
352+ status_counts : dict [ str , int ] = {}
308353 for task in tasks :
309- status = task .status
310- status_counts [status ] = status_counts .get (status , 0 ) + 1
354+ status = task ["status" ]
355+ if status :
356+ status_counts [status ] = status_counts .get (status , 0 ) + 1
311357
312358 return {
313359 "project_name" : project_name ,
314360 "total_tasks" : len (tasks ),
315361 "status_breakdown" : status_counts ,
316- "task_types" : list ( set ( task . type for task in tasks if hasattr ( task , "type" )) ),
362+ "task_types" : sorted ({ task [ " type" ] for task in tasks if task [ "type" ]} ),
317363 }
318364 except Exception as e :
319365 return {"error" : f"Failed to get project stats: { e !s} " }
@@ -364,44 +410,22 @@ async def compare_tasks(task_ids: list[str], metrics: list[str] | None = None) -
364410async def search_tasks (query : str , project_name : str | None = None ) -> list [dict [str , Any ]]:
365411 """Search tasks by name, tags, or description."""
366412 try :
367- # Task.query_tasks returns task IDs (strings), not task objects
368- task_ids = Task .query_tasks (project_name = project_name )
369-
370- matching_tasks = []
371- query_lower = query .lower ()
372-
373- for task_id in task_ids :
374- try :
375- task = Task .get_task (task_id = task_id )
376-
377- # Check if the task matches the search query
378- task_name = task .name .lower ()
379- task_comment = getattr (task , "comment" , "" ) or ""
380- task_tags = list (task .data .tags ) if task .data .tags else []
381-
382- if (
383- query_lower in task_name
384- or (task_comment and query_lower in task_comment .lower ())
385- or any (query_lower in tag .lower () for tag in task_tags )
386- ):
387- matching_tasks .append (
388- {
389- "id" : task .id ,
390- "name" : task .name ,
391- "status" : task .status ,
392- "project" : task .get_project_name (),
393- "created" : str (task .data .created ),
394- "tags" : task_tags ,
395- "comment" : task_comment ,
396- }
397- )
398- except Exception as e :
399- # If we can't get a specific task, skip it but log the error
400- matching_tasks .append (
401- {"id" : task_id , "error" : f"Failed to get task details: { e !s} " }
402- )
403-
404- return matching_tasks
413+ # Match the query as a literal substring (case-insensitive) against
414+ # 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 )
417+ return [
418+ {
419+ "id" : t ["id" ],
420+ "name" : t ["name" ],
421+ "status" : t ["status" ],
422+ "project" : t ["project" ],
423+ "created" : t ["created" ],
424+ "tags" : t ["tags" ],
425+ "comment" : t ["comment" ],
426+ }
427+ for t in tasks
428+ ]
405429 except Exception as e :
406430 return [{"error" : f"Failed to search tasks: { e !s} " }]
407431
0 commit comments