3333import json
3434import os
3535import sys
36+ import time
3637
3738import httpx
3839from mcp .server .fastmcp import FastMCP
@@ -51,7 +52,7 @@ def _headers() -> dict[str, str]:
5152async def _get (path : str , params : dict | None = None ) -> any :
5253 hdrs = _headers ()
5354 hdrs ["Accept-Encoding" ] = "identity"
54- async with httpx .AsyncClient (timeout = 30 ) as client :
55+ async with httpx .AsyncClient (timeout = 60 ) as client :
5556 r = await client .get (f"{ API } /{ path } " , params = params , headers = hdrs )
5657 r .raise_for_status ()
5758 return r .json ()
@@ -93,24 +94,13 @@ def _parse_job_status(raw: str | None) -> dict:
9394 "active" : active , "wait" : max (0 , wait )}
9495
9596
96- @mcp .tool ()
97- async def list_ongoing_trains () -> str :
98- """List all currently running / ready Hyperloop train runs.
99-
100- Returns a compact table with train ID, dataset, state, job progress,
101- error rate, and package tag. One API call.
102- """
103- trains = await _get ("trains/all-trains.jsp" , {"state" : "ready" })
104- if not trains :
105- return "No ongoing trains."
106-
97+ def _format_train_table (trains : list [dict ]) -> str :
10798 lines = []
10899 lines .append (f"{ 'ID' :>8} { 'State' :<11} { 'Done/Total' :>12} { 'Err%' :>5} "
109100 f"{ 'Dataset' :<40} { 'Package' } " )
110101 lines .append ("-" * 120 )
111102
112- for t in sorted (trains , key = lambda x : _parse_job_status (
113- x .get ("job_status" )).get ("total" , 0 ), reverse = True ):
103+ for t in trains :
114104 js = _parse_job_status (t .get ("job_status" ))
115105 total = js .get ("total" , 0 )
116106 done = js .get ("done" , 0 )
@@ -125,19 +115,65 @@ async def list_ongoing_trains() -> str:
125115 f"{ done :>6} /{ total :<6} { err_pct :>5} "
126116 f"{ ds :<40} { pkg } "
127117 )
128-
129- lines .append (f"\n Total: { len (trains )} trains" )
130118 return "\n " .join (lines )
131119
132120
121+ @mcp .tool ()
122+ async def list_ongoing_trains () -> str :
123+ """List all currently running / ready Hyperloop train runs.
124+
125+ Returns a compact table with train ID, dataset, state, job progress,
126+ error rate, and package tag. One API call.
127+ """
128+ trains = await _get ("trains/all-trains.jsp" , {"state" : "ready" })
129+ if not trains :
130+ return "No ongoing trains."
131+
132+ trains .sort (key = lambda x : _parse_job_status (
133+ x .get ("job_status" )).get ("total" , 0 ), reverse = True )
134+
135+ result = _format_train_table (trains )
136+ result += f"\n \n Total: { len (trains )} trains"
137+ return result
138+
139+
140+ @mcp .tool ()
141+ async def search_trains (dataset : str , last_n : int = 10 ) -> str :
142+ """Search for recent trains (including finished) on a given dataset.
143+
144+ Uses the dataset name for server-side coarse filtering, then exact-matches
145+ client-side. Returns the most recent `last_n` trains (by ID descending).
146+
147+ Args:
148+ dataset: Exact dataset name (e.g. "LHC25ae_pass2_small").
149+ last_n: Number of most recent trains to return (default 10).
150+ """
151+ raw = await _get ("trains/all-trains.jsp" , {"dataset_name" : dataset })
152+ if not raw :
153+ return f"No trains found for dataset '{ dataset } '."
154+
155+ # Server returns fuzzy matches; exact-filter client-side
156+ exact = [t for t in raw if t .get ("dataset_name" ) == dataset ]
157+ if not exact :
158+ return f"No trains found with exact dataset name '{ dataset } '."
159+
160+ # Most recent first
161+ exact .sort (key = lambda t : t .get ("id" , 0 ), reverse = True )
162+ exact = exact [:last_n ]
163+
164+ result = _format_train_table (exact )
165+ result += f"\n \n Showing { len (exact )} most recent (of { len ([t for t in raw if t .get ('dataset_name' ) == dataset ])} total)"
166+ return result
167+
168+
133169@mcp .tool ()
134170async def train_detail (train_id : int ) -> str :
135- """Get resource metrics for a specific train run.
171+ """Get resource metrics for a specific train run (ongoing or finished) .
136172
137173 Shows CPU time, wall time, memory (PSS), throughput, input/output
138174 sizes, target, and merge status. One API call.
139175 """
140- t = await _get ("trains/train.jsp" , {"train_id" : train_id , "type" : "ready" })
176+ t = await _get ("trains/train.jsp" , {"train_id" : train_id })
141177
142178 lines = [f"Train { t ['id' ]} : { t .get ('dataset_name' , '?' )} " ]
143179 lines .append (f" State: { t .get ('state' )} " )
@@ -169,13 +205,13 @@ async def train_detail(train_id: int) -> str:
169205
170206@mcp .tool ()
171207async def wagon_stats (train_id : int ) -> str :
172- """Get per-wagon CPU and memory breakdown for a train.
208+ """Get per-wagon CPU and memory breakdown for a train (ongoing or finished) .
173209
174210 Fetches wagon IDs from the train, then retrieves grid statistics
175211 for each wagon. Typically 10-20 wagons, one API call each.
176212 """
177213 # First get train detail for dataset_id and wagons_timestamp
178- t = await _get ("trains/train.jsp" , {"train_id" : train_id , "type" : "ready" })
214+ t = await _get ("trains/train.jsp" , {"train_id" : train_id })
179215 dataset_id = t .get ("dataset_id" )
180216 wagons_ts = t .get ("wagons_timestamp" ) or t .get ("dataset_timestamp" )
181217
0 commit comments