rohit-sahoo commited on
Commit
32ea6e3
·
1 Parent(s): 81232b9

Add the MCP server panel, and pin the MCP deps it relies on

Browse files

The tool servers sleep when idle, and a cold one made the agent look broken with no way
to tell why. The panel opens as a floating overlay beside the model controls — an
accordion pushed the whole page down — and every row carries its own refresh, so waking
one cold server never re-hits the healthy ones. A dot beside the label aggregates status
so a down server is visible without opening anything. Rows paint "Checking…" before the
requests start: a cold server can take ~10s, and without that the header refresh looked
like it did nothing.

Refreshes run with queue=False. The app serializes queued work at concurrency 1, so a
queued refresh would hold the only slot and stall every visitor's chat turn behind it.

fastmcp-slim and mcp are pinned in the same commit because this push triggers a rebuild,
and an unpinned rebuild is what just took MIO down: fastmcp-slim 4.0.0 dropped the
mcp<2.0 cap, pip took mcp 2.x, and 2.x renamed ToolsCapability.listChanged, which
pydantic-ai 2.16.0 still reads. Shipping the panel without the pins would have broken
every query on arrival.

Files changed (2) hide show
  1. app.py +436 -0
  2. requirements.txt +8 -0
app.py CHANGED
@@ -19,6 +19,8 @@ Run locally:
19
  from __future__ import annotations
20
 
21
  import hashlib
 
 
22
  import os
23
  import re
24
  import time
@@ -129,6 +131,256 @@ def _probe_mcp_servers() -> tuple[list, set[str]]:
129
 
130
  MCP_TOOLSETS, MCP_TOOLS_AVAILABLE = _probe_mcp_servers()
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  # ── AKD Guardrails (service-relayed: gliguard on input, risk_agent on output) ──
133
  # All guard logic lives server-side in the NASA-IMPACT/akd-guardrails service —
134
  # this app only relays verdicts, attached the pydantic-ai v2 way as harness
@@ -1249,6 +1501,118 @@ footer { display: none !important; }
1249
  background: transparent !important; border: none !important; padding: 0 !important;
1250
  color: #14162a !important; font-size: 12.5px !important; line-height: 1.7 !important;
1251
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1252
  """
1253
 
1254
 
@@ -1382,6 +1746,28 @@ with gr.Blocks(title="Code Search Agent") as demo:
1382
  info="Speed vs. depth.",
1383
  scale=1, min_width=180,
1384
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
 
1386
  with gr.Column(elem_id="chatcard"):
1387
  chatbot = gr.Chatbot(
@@ -1430,6 +1816,56 @@ with gr.Blocks(title="Code Search Agent") as demo:
1430
 
1431
  # Submit flow: add user bubble + clear box, then stream the agent's reply.
1432
  outputs = [chatbot, run_context]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1433
  send.click(_user_submit, [msg, chatbot], [msg, chatbot], queue=False).then(
1434
  _bot_respond, [chatbot, api_key, model_dd, effort_dd, run_context], outputs
1435
  )
 
19
  from __future__ import annotations
20
 
21
  import hashlib
22
+ import threading
23
+ import json
24
  import os
25
  import re
26
  import time
 
131
 
132
  MCP_TOOLSETS, MCP_TOOLS_AVAILABLE = _probe_mcp_servers()
133
 
134
+ # The JSON-RPC handshake the agent itself sends, so a probe sees exactly what the
135
+ # agent would see rather than a bare reachability guess (a 401 or 500 is not "up").
136
+ _MCP_INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
137
+ "params": {"protocolVersion": "2025-03-26", "capabilities": {},
138
+ "clientInfo": {"name": "akd-probe", "version": "0"}}}
139
+
140
+
141
+ # ── Tool warm-up (manual refresh) ───────────────────────────────────────────────
142
+ # The hosted MCP servers are FastMCP instances that sleep when idle. On a cold
143
+ # Space the first query races their wake-up: the startup probe marks them down,
144
+ # build_toolsets() skips them, and the agent answers with only the tools that
145
+ # happened to be awake — which looks like the agent "not working properly" rather
146
+ # than like an outage. This is the akd-labs services/mcp_warmup.py approach: send
147
+ # the real JSON-RPC `initialize` each server would receive from the agent, so we
148
+ # see the same failure the agent would (auth rejected, cold-start timeout, broken
149
+ # JSON-RPC handler), and report it per server instead of a bare reachable/not.
150
+ _WARMUP_TIMEOUT = float(os.environ.get("MCP_WARMUP_TIMEOUT", "20"))
151
+ # The button is public, so it needs a bound. Without one, every click fans out a POST
152
+ # per server to NASA's FastMCP instances, and N visitors clicking repeatedly becomes an
153
+ # amplifier pointed at someone else's infrastructure. Two guards: a single-flight lock
154
+ # so concurrent clicks share ONE warm-up instead of each starting their own, and a
155
+ # cooldown so clicks inside the window return the last result (labelled with its age)
156
+ # rather than re-probing. Server state is shared by every visitor anyway — they all use
157
+ # the same endpoints — so a shared, briefly-cached answer is the honest one.
158
+ _WARMUP_COOLDOWN = float(os.environ.get("MCP_WARMUP_COOLDOWN", "30"))
159
+ # Per-server, not global: warming every server on each click hammered endpoints that
160
+ # were already healthy. Each server owns its cooldown and its own single-flight lock,
161
+ # so refreshing one never touches the others and simultaneous clicks on the SAME server
162
+ # collapse into one request.
163
+ _WARMUP_STATE: dict = {} # server name -> {"t": float, "result": dict}
164
+ _WARMUP_LOCKS: dict = {} # server name -> threading.Lock
165
+ _WARMUP_LOCKS_GUARD = threading.Lock()
166
+
167
+
168
+ def _server_lock(name: str) -> threading.Lock:
169
+ with _WARMUP_LOCKS_GUARD:
170
+ return _WARMUP_LOCKS.setdefault(name, threading.Lock())
171
+
172
+
173
+ def _parse_jsonrpc(text: str, content_type: str):
174
+ """A JSON-RPC object out of either a plain JSON body or an SSE `data:` frame."""
175
+ body = (text or "").strip()
176
+ if "text/event-stream" in (content_type or ""):
177
+ for line in body.splitlines():
178
+ line = line.strip()
179
+ if line.startswith("data:"):
180
+ try:
181
+ obj = json.loads(line[5:].strip())
182
+ except Exception:
183
+ continue
184
+ if isinstance(obj, dict):
185
+ return obj
186
+ return None
187
+ try:
188
+ obj = json.loads(body)
189
+ except Exception:
190
+ return None
191
+ return obj if isinstance(obj, dict) else None
192
+
193
+
194
+ def _warmup_one(client, name: str, url: str, headers: dict, timeout: float) -> dict:
195
+ """One `initialize` round-trip, classified the way the agent would experience it."""
196
+ import time as _time
197
+
198
+ started = _time.perf_counter()
199
+ try:
200
+ r = client.post(url, json=_MCP_INIT, headers=headers, timeout=timeout,
201
+ follow_redirects=True)
202
+ ms = int((_time.perf_counter() - started) * 1000)
203
+ if r.status_code in (401, 403):
204
+ return {"name": name, "status": "down", "ms": ms, "http": r.status_code,
205
+ "detail": "auth rejected"}
206
+ if r.status_code >= 400:
207
+ # detail stays empty: the row already renders "· HTTP {code}" from `http`,
208
+ # and duplicating it read as "Unavailable · HTTP 404 · HTTP 404".
209
+ return {"name": name, "status": "down", "ms": ms, "http": r.status_code,
210
+ "detail": ""}
211
+ rpc = _parse_jsonrpc(r.text, r.headers.get("content-type", ""))
212
+ if rpc is None:
213
+ return {"name": name, "status": "down", "ms": ms, "http": r.status_code,
214
+ "detail": "not an MCP endpoint"}
215
+ if isinstance(rpc.get("error"), dict):
216
+ return {"name": name, "status": "down", "ms": ms, "http": r.status_code,
217
+ "detail": rpc["error"].get("message") or "MCP error"}
218
+ if "result" in rpc:
219
+ return {"name": name, "status": "up", "ms": ms, "http": r.status_code,
220
+ "detail": ""}
221
+ return {"name": name, "status": "down", "ms": ms, "http": r.status_code,
222
+ "detail": "no JSON-RPC result"}
223
+ except Exception as exc:
224
+ ms = int((_time.perf_counter() - started) * 1000)
225
+ kind = "still waking (timed out)" if "Timeout" in type(exc).__name__ else type(exc).__name__
226
+ return {"name": name, "status": "down", "ms": ms, "http": None, "detail": kind}
227
+
228
+ def http_server_targets() -> dict:
229
+ """{server name: (url, headers)} — None where the token is missing. Built from
230
+ MCP_SERVER_SPECS so the panel always mirrors the servers the agent actually uses."""
231
+ out: dict = {}
232
+ for name, url_env, url_default, key_env, _allow in MCP_SERVER_SPECS:
233
+ url = _env(url_env) or url_default
234
+ key = _env(key_env)
235
+ headers = {"content-type": "application/json",
236
+ "accept": "application/json, text/event-stream"}
237
+ if not key:
238
+ out[name] = None
239
+ continue
240
+ headers["Authorization"] = f"Bearer {key}"
241
+ out[name] = (url, headers)
242
+ return out
243
+
244
+
245
+ SERVER_NAMES = list(http_server_targets().keys())
246
+
247
+
248
+ def _publish_state() -> None:
249
+ """No-op here, deliberately.
250
+
251
+ MIO reseeds a 120s probe cache at this point so a just-woken server is picked up by
252
+ the next query. This agent resolves its toolsets once at startup (see
253
+ _probe_mcp_servers), so there is no cache to reseed: the panel is diagnostic, and
254
+ waking a cold server warms it for subsequent calls without changing which tools this
255
+ process already loaded. Restart the Space to pick up a server that was down at boot.
256
+ """
257
+ return
258
+
259
+
260
+ def warmup_server(name: str, timeout: float | None = None) -> tuple[dict, int]:
261
+ """Warm ONE server. Returns (result, age_seconds); age > 0 means the cooldown
262
+ served the previous result instead of re-probing.
263
+
264
+ Cooldown + single-flight are per server, so a click on `geoui` never sends a
265
+ request to `cmr`, and two people clicking `geoui` together produce one request.
266
+ """
267
+ import httpx
268
+
269
+ target = http_server_targets().get(name, None)
270
+ if target is None:
271
+ res = {"name": name, "status": "skipped", "ms": 0, "http": None,
272
+ "detail": "no token configured"}
273
+ _WARMUP_STATE[name] = {"t": time.monotonic(), "result": res}
274
+ return res, 0
275
+
276
+ st = _WARMUP_STATE.get(name)
277
+ now = time.monotonic()
278
+ if st and st.get("result") and now - st["t"] < _WARMUP_COOLDOWN:
279
+ return st["result"], int(now - st["t"])
280
+
281
+ with _server_lock(name):
282
+ st = _WARMUP_STATE.get(name) # another click may have just done it
283
+ now = time.monotonic()
284
+ if st and st.get("result") and now - st["t"] < _WARMUP_COOLDOWN:
285
+ return st["result"], int(now - st["t"])
286
+ url, headers = target
287
+ timeout = timeout or _WARMUP_TIMEOUT
288
+ with httpx.Client() as client:
289
+ res = _warmup_one(client, name, url, headers, timeout)
290
+ # A timeout means it was probably asleep and this request booted it, so the
291
+ # retry is the one that finds it awake — that IS the point of the button.
292
+ if res["status"] == "down" and "waking" in res["detail"]:
293
+ res = _warmup_one(client, name, url, headers, timeout)
294
+ if res["status"] == "up":
295
+ res["detail"] = "woken"
296
+ _WARMUP_STATE[name] = {"t": time.monotonic(), "result": res}
297
+ _publish_state()
298
+ return res, 0
299
+
300
+
301
+ def warmup_pending(timeout: float | None = None) -> list[tuple[dict, int]]:
302
+ """Warm only servers not currently known-good — never the healthy ones.
303
+
304
+ This is what the bulk control uses, so a click costs one request per *problem*
305
+ server instead of one per server. With everything up it costs nothing.
306
+ """
307
+ import concurrent.futures as _cf
308
+
309
+ pending = [n for n in SERVER_NAMES
310
+ if (_WARMUP_STATE.get(n, {}).get("result") or {}).get("status") != "up"]
311
+ if not pending:
312
+ return []
313
+ with _cf.ThreadPoolExecutor(len(pending)) as ex:
314
+ return list(ex.map(lambda n: warmup_server(n, timeout), pending))
315
+
316
+ def _server_row_html(name: str, res: dict | None = None, age: int = 0) -> str:
317
+ """One popover row: status dot, server name, and a detail line in the akd-labs
318
+ wording (`Available · HTTP 200 · 352ms`). Colour lives in the dot, not the row
319
+ background — Gradio 6's Svelte styling overrides even an inline `!important`
320
+ background on its own controls, but a plain <span> is untouched by that."""
321
+ state = (res or {}).get("status")
322
+ dot = {"up": "mdot-up", "down": "mdot-down", "skipped": "mdot-skip"}.get(state, "mdot-idle")
323
+ if res is None:
324
+ detail = "Not checked yet"
325
+ elif state == "up":
326
+ bits = ["Available"]
327
+ if res.get("http") is not None:
328
+ bits.append(f"HTTP {res['http']}")
329
+ bits.append(f"{res['ms']}ms")
330
+ if age:
331
+ bits.append(f"checked {age}s ago")
332
+ detail = " · ".join(bits)
333
+ elif state == "skipped":
334
+ detail = res.get("detail") or "Skipped"
335
+ else:
336
+ bits = ["Unavailable"]
337
+ if res.get("http") is not None:
338
+ bits.append(f"HTTP {res['http']}")
339
+ if res.get("detail"):
340
+ bits.append(res["detail"])
341
+ detail = " · ".join(bits)
342
+ return (f'<div class="mrow"><span class="mdot {dot}"></span>'
343
+ f'<span class="mtext"><span class="mname">{name}</span>'
344
+ f'<span class="mstat">{detail}</span></span></div>')
345
+
346
+
347
+ def _checking_html(name: str) -> str:
348
+ return (f'<div class="mrow"><span class="mdot mdot-idle"></span>'
349
+ f'<span class="mtext"><span class="mname">{name}</span>'
350
+ f'<span class="mstat">Checking…</span></span></div>')
351
+
352
+
353
+ def _label_html() -> str:
354
+ """Field label with an aggregate status dot, so a glance tells you whether anything
355
+ is down without opening the panel. Grey until something has been checked — claiming
356
+ green before we have probed would be a guess. The tooltip carries the counts."""
357
+ results = [(_WARMUP_STATE.get(n) or {}).get("result") for n in SERVER_NAMES]
358
+ checked = [r for r in results if r]
359
+ if not SERVER_NAMES:
360
+ cls, tip = "agg-idle", "No remote tool servers configured"
361
+ elif not checked:
362
+ cls, tip = "agg-idle", f"{len(SERVER_NAMES)} servers · not checked yet"
363
+ else:
364
+ up = sum(1 for r in checked if r["status"] == "up")
365
+ bad = len(checked) - up
366
+ unchecked = len(SERVER_NAMES) - len(checked)
367
+ cls = "agg-down" if bad else ("agg-idle" if unchecked else "agg-up")
368
+ bits = ([f"{up} up"] if up else []) + ([f"{bad} unavailable"] if bad else []) \
369
+ + ([f"{unchecked} unchecked"] if unchecked else [])
370
+ tip = " · ".join(bits)
371
+ return (f'<span class="mcplabel">🛠️ MCP servers'
372
+ f'<span class="aggdot {cls}" title="{tip}"></span></span>'
373
+ f'<span class="mcpinfo-line">Status &amp; refresh.</span>')
374
+
375
+
376
+ def _tools_face() -> str:
377
+ """Static face for the trigger. It deliberately does NOT carry the up/unavailable
378
+ counts: that string wrapped to two lines and stretched the field out of line with
379
+ its neighbours, and the panel behind it already reports per-server state."""
380
+ n = len(SERVER_NAMES)
381
+ return f"{n} server{'s' if n != 1 else ''}" if n else "none configured"
382
+
383
+
384
  # ── AKD Guardrails (service-relayed: gliguard on input, risk_agent on output) ──
385
  # All guard logic lives server-side in the NASA-IMPACT/akd-guardrails service —
386
  # this app only relays verdicts, attached the pydantic-ai v2 way as harness
 
1501
  background: transparent !important; border: none !important; padding: 0 !important;
1502
  color: #14162a !important; font-size: 12.5px !important; line-height: 1.7 !important;
1503
  }
1504
+
1505
+ /* MCP server popover — an OVERLAY, not an accordion: expanding a panel pushed the
1506
+ whole page down, so this is absolutely positioned and floats above the content, with
1507
+ a refresh icon on every row (mirrors the akd-labs MCP status popover). */
1508
+ /* #keyrow bottom-aligns its children, and this column is shorter than the native
1509
+ blocks (the key field's info wraps to two lines), so its label sat ~44px below the
1510
+ others. Stretch to the row height and push label to the top / field to the bottom,
1511
+ which lines up BOTH edges with the native controls. */
1512
+ #toolscol { position: relative !important; gap: 0 !important;
1513
+ align-self: stretch !important; justify-content: space-between !important;
1514
+ /* native blocks carry padding:10px 12px — without it the label sat 10px
1515
+ high and the field 12px low against its neighbours */
1516
+ padding: 10px 0 12px !important; }
1517
+ #mcplabelwrap { flex: 0 0 auto !important; }
1518
+ /* label + info line, matching the Model/Effort blocks exactly */
1519
+ #mcplabelwrap, #mcplabelwrap .block { background: transparent !important; border: none !important;
1520
+ padding: 0 !important; min-height: 0 !important; }
1521
+ .mcplabel { display: flex; align-items: center; gap: 7px; color: #14162a;
1522
+ font-weight: 600; font-size: 14.5px; font-family: 'IBM Plex Sans', sans-serif;
1523
+ margin-bottom: 2px; }
1524
+ /* aggregate dot: green when every checked server is up, red as soon as one is down,
1525
+ grey while anything is still unchecked — never green on an assumption */
1526
+ .aggdot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
1527
+ .aggdot.agg-up { background: #10b981; box-shadow: 0 0 0 3px rgba(16,185,129,.16); }
1528
+ .aggdot.agg-down { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.16); }
1529
+ .aggdot.agg-idle { background: #c9cbd8; }
1530
+ .mcpinfo-line { display: block; color: #8a8fa6; font-size: 12.5px;
1531
+ font-family: 'IBM Plex Sans', sans-serif; margin-bottom: 6px; }
1532
+ /* the trigger IS the field: same fill, border, radius and height as the selects */
1533
+ #mcptrigger {
1534
+ background: #f4f3ef !important; border: 1px solid rgba(20,22,40,0.14) !important;
1535
+ border-radius: 10px !important; min-height: 42px !important; height: 42px !important;
1536
+ color: #14162a !important; font-family: 'IBM Plex Mono', monospace !important;
1537
+ /* 400, not Gradio's button default of 600 — the native select values are regular */
1538
+ font-size: 14px !important; font-weight: 400 !important;
1539
+ text-align: left !important; justify-content: flex-start !important;
1540
+ padding: 8px 40px 8px 12px !important; box-shadow: none !important; width: 100% !important;
1541
+ position: relative !important;
1542
+ }
1543
+ /* the caret is Gradio's own dropdown-arrow SVG (20x20, path M5 8l4 4 4-4z, inset 21px)
1544
+ inlined as a data URI — a "▾" text glyph rendered smaller and lighter than the real
1545
+ selects beside it */
1546
+ #mcptrigger::after {
1547
+ content: ""; position: absolute; right: 21px; top: 50%; transform: translateY(-50%);
1548
+ width: 20px; height: 20px; pointer-events: none;
1549
+ background: currentColor;
1550
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'><path d='M5 8l4 4 4-4z'/></svg>") center/100% no-repeat;
1551
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'><path d='M5 8l4 4 4-4z'/></svg>") center/100% no-repeat;
1552
+ }
1553
+ #mcptrigger:hover { filter: brightness(0.99); }
1554
+ #mcppanel {
1555
+ position: absolute !important; top: calc(100% + 6px); right: 0; z-index: 60;
1556
+ width: 430px; max-width: 92vw; background: #fff !important;
1557
+ border: 1px solid rgba(20,22,40,.12) !important; border-radius: 12px !important;
1558
+ box-shadow: 0 12px 32px rgba(20,22,40,.16) !important;
1559
+ padding: 0 !important; gap: 0 !important; max-height: 340px; overflow-y: auto;
1560
+ }
1561
+ #mcphdr {
1562
+ gap: 6px !important; align-items: center !important;
1563
+ border-bottom: 1px solid rgba(20,22,40,.10) !important;
1564
+ padding: 9px 12px !important; margin: 0 !important; position: sticky; top: 0;
1565
+ background: #fff; z-index: 2;
1566
+ }
1567
+ .mcptitle { font-family: 'IBM Plex Sans', sans-serif; font-size: 13.5px;
1568
+ font-weight: 600; color: #14162a; }
1569
+ #mcprefreshall, #mcpclose {
1570
+ font-size: 19px !important; line-height: 1 !important; color: #565b73 !important;
1571
+ background: transparent !important; border: none !important; box-shadow: none !important;
1572
+ padding: 6px 9px !important; min-height: 0 !important; height: auto !important;
1573
+ border-radius: 8px !important; flex: 0 0 auto !important; width: max-content !important;
1574
+ }
1575
+ #mcprefreshall:hover, #mcpclose:hover { color: #14162a !important; background: #f2f2f6 !important; }
1576
+ .mcprow { gap: 6px !important; align-items: center !important; margin: 0 !important;
1577
+ padding: 2px 12px !important; }
1578
+ .mcprow:hover { background: #f7f7fb; }
1579
+ /* Gradio stamps `min-width: min(320px, 100%)` inline on every block, which in a 430px
1580
+ popover forced the refresh icon onto its own line. Zero the floor and forbid wrapping
1581
+ so the icon sits to the right of its server, as in the reference. */
1582
+ .mcprow, #mcphdr { flex-wrap: nowrap !important; }
1583
+ .mcpinfo, #mcphdrtitle { flex: 1 1 auto !important; min-width: 0 !important; }
1584
+ .mcpinfo, .mcpinfo .block { background: transparent !important; border: none !important;
1585
+ padding: 0 !important; min-height: 0 !important; }
1586
+ .mrow { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; }
1587
+ .mdot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
1588
+ .mdot-up { background: #10b981; }
1589
+ .mdot-down { background: #ef4444; }
1590
+ .mdot-skip { background: #9aa0b4; }
1591
+ .mdot-idle { background: #c9cbd8; }
1592
+ .mtext { display: flex; flex-direction: column; line-height: 1.4; }
1593
+ .mname { font-family: 'IBM Plex Sans', sans-serif; font-size: 13.5px; font-weight: 600;
1594
+ color: #14162a; }
1595
+ .mstat { font-family: 'IBM Plex Sans', sans-serif; font-size: 12px; color: #8a8fa6; }
1596
+ /* the per-row refresh icon, right-aligned like the reference */
1597
+ .mcprowbtn {
1598
+ font-size: 19px !important; line-height: 1 !important; color: #565b73 !important;
1599
+ background: transparent !important; border: none !important; box-shadow: none !important;
1600
+ padding: 8px 10px !important; min-height: 34px !important; height: auto !important;
1601
+ border-radius: 8px !important; flex: 0 0 auto !important; width: max-content !important;
1602
+ }
1603
+ .mcprowbtn:hover { color: #3d33ab !important; background: #efeefb !important; }
1604
+ /* the inline min-width Gradio sets on buttons would otherwise widen these icons */
1605
+ .mcprowbtn, #mcprefreshall, #mcpclose { min-width: 0 !important; }
1606
+ #toolstatus { flex: 0 0 auto !important; }
1607
+ #toolstatus, #toolstatus .block { background: transparent !important; border: none !important;
1608
+ padding: 0 !important; min-height: 0 !important; }
1609
+ #toolstatus .tsum, #toolstatus .tmuted, #toolstatus .tbad {
1610
+ font-family: 'IBM Plex Mono', monospace; font-size: 11.5px;
1611
+ }
1612
+ #toolstatus .tsum { color: #0b7c45; }
1613
+ #toolstatus .tbad { color: #b02a2a; }
1614
+ #toolstatus .tmuted { color: #8a8fa6; }
1615
+
1616
  """
1617
 
1618
 
 
1746
  info="Speed vs. depth.",
1747
  scale=1, min_width=180,
1748
  )
1749
+ # Cold-start escape hatch, styled as a field so it reads like the Model and
1750
+ # Effort selects beside it. The panel it opens is a floating overlay rather
1751
+ # than an accordion, which pushed the whole page down; every row carries its
1752
+ # own refresh so waking one cold server never re-hits the healthy ones.
1753
+ with gr.Column(scale=1, min_width=150, elem_id="toolscol"):
1754
+ tools_label = gr.HTML(_label_html(), elem_id="mcplabelwrap")
1755
+ tools_trigger = gr.Button(_tools_face(), elem_id="mcptrigger")
1756
+ with gr.Column(elem_id="mcppanel", visible=False) as tools_panel:
1757
+ with gr.Row(elem_id="mcphdr"):
1758
+ gr.HTML('<span class="mcptitle">MCP servers</span>',
1759
+ elem_id="mcphdrtitle")
1760
+ refresh_all = gr.Button("⟳", size="sm", scale=0, min_width=0,
1761
+ elem_id="mcprefreshall")
1762
+ close_panel = gr.Button("✕", size="sm", scale=0, min_width=0,
1763
+ elem_id="mcpclose")
1764
+ tool_rows: dict = {}
1765
+ for _n in SERVER_NAMES:
1766
+ with gr.Row(elem_classes="mcprow"):
1767
+ _info = gr.HTML(_server_row_html(_n), elem_classes="mcpinfo")
1768
+ _btn = gr.Button("⟳", size="sm", scale=0, min_width=0,
1769
+ elem_classes="mcprowbtn")
1770
+ tool_rows[_n] = (_info, _btn)
1771
 
1772
  with gr.Column(elem_id="chatcard"):
1773
  chatbot = gr.Chatbot(
 
1816
 
1817
  # Submit flow: add user bubble + clear box, then stream the agent's reply.
1818
  outputs = [chatbot, run_context]
1819
+ panel_open = gr.State(False)
1820
+
1821
+ def _toggle_panel(is_open: bool):
1822
+ return gr.update(visible=not is_open), (not is_open)
1823
+
1824
+ def _refresh_one(name: str):
1825
+ """Refresh exactly one server: its own row and the status light, nothing else."""
1826
+ res, age = warmup_server(name)
1827
+ return _server_row_html(name, res, age), _label_html()
1828
+
1829
+ def _refresh_all():
1830
+ """Header refresh — only servers that aren't ready, so with everything up it
1831
+ sends nothing."""
1832
+ warmup_pending()
1833
+ rows = [_server_row_html(n, (_WARMUP_STATE.get(n) or {}).get("result"))
1834
+ for n in SERVER_NAMES]
1835
+ return [*rows, _label_html()]
1836
+
1837
+ def _mark_checking():
1838
+ """Paint "Checking…" on every row this pass will actually contact, BEFORE the
1839
+ requests start: a cold server can take ~10s, and without this the header refresh
1840
+ looked broken. Rows already up are left alone — they aren't being re-hit."""
1841
+ out = []
1842
+ for n in SERVER_NAMES:
1843
+ res = (_WARMUP_STATE.get(n) or {}).get("result")
1844
+ out.append(_server_row_html(n, res) if (res or {}).get("status") == "up"
1845
+ else _checking_html(n))
1846
+ return out
1847
+
1848
+ # queue=False is load-bearing: demo.queue(default_concurrency_limit=1) serializes
1849
+ # queued work, so a queued refresh would hold the single slot and stall every
1850
+ # visitor's chat turn behind it. show_progress="hidden" keeps Gradio's unanchored
1851
+ # spinner off the card; each row shows its own "Checking…" state instead.
1852
+ tools_trigger.click(_toggle_panel, panel_open, [tools_panel, panel_open],
1853
+ queue=False, show_progress="hidden")
1854
+ close_panel.click(lambda: (gr.update(visible=False), False), None,
1855
+ [tools_panel, panel_open], queue=False, show_progress="hidden")
1856
+
1857
+ for _name, (_info, _btn) in tool_rows.items():
1858
+ _btn.click(lambda n=_name: _checking_html(n), None, _info,
1859
+ queue=False, show_progress="hidden").then(
1860
+ lambda n=_name: _refresh_one(n), None, [_info, tools_label],
1861
+ queue=False, show_progress="hidden")
1862
+
1863
+ refresh_all.click(
1864
+ _mark_checking, None, [i for i, _ in tool_rows.values()],
1865
+ queue=False, show_progress="hidden",
1866
+ ).then(_refresh_all, None, [*(i for i, _ in tool_rows.values()), tools_label],
1867
+ queue=False, show_progress="hidden")
1868
+
1869
  send.click(_user_submit, [msg, chatbot], [msg, chatbot], queue=False).then(
1870
  _bot_respond, [chatbot, api_key, model_dd, effort_dd, run_context], outputs
1871
  )
requirements.txt CHANGED
@@ -1,5 +1,13 @@
1
  gradio==6.19.0
2
  pydantic-ai==2.16.0
 
 
 
 
 
 
 
 
3
  pydantic-ai-harness==0.10.0
4
  python-dotenv>=1.0.0
5
  markdown-it-py
 
1
  gradio==6.19.0
2
  pydantic-ai==2.16.0
3
+ # fastmcp-slim and mcp reach us only as transitive deps of pydantic-ai, so they
4
+ # were unpinned and every rebuild took whatever was newest. fastmcp-slim 4.0.0
5
+ # dropped the mcp<2.0 cap, pip then took mcp 2.x, and 2.x renamed
6
+ # ToolsCapability.listChanged — which pydantic-ai 2.16.0 still reads. That took
7
+ # the MIO agent down on 2026-09-01: every query died. Pinned to the versions
8
+ # this agent is verified running.
9
+ fastmcp-slim==3.4.4
10
+ mcp==1.28.1
11
  pydantic-ai-harness==0.10.0
12
  python-dotenv>=1.0.0
13
  markdown-it-py