fast-agent-mcp 0.3.8__py3-none-any.whl → 0.3.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fast-agent-mcp might be problematic. Click here for more details.
- fast_agent/agents/llm_agent.py +24 -0
- fast_agent/agents/mcp_agent.py +7 -1
- fast_agent/core/direct_factory.py +20 -8
- fast_agent/llm/provider/anthropic/llm_anthropic.py +107 -62
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +4 -3
- fast_agent/llm/provider/google/google_converter.py +8 -41
- fast_agent/llm/provider/openai/llm_openai.py +3 -3
- fast_agent/mcp/mcp_agent_client_session.py +45 -2
- fast_agent/mcp/mcp_aggregator.py +314 -33
- fast_agent/mcp/mcp_connection_manager.py +86 -10
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/transport_tracking.py +600 -0
- fast_agent/resources/examples/data-analysis/analysis.py +7 -3
- fast_agent/ui/console_display.py +22 -1
- fast_agent/ui/elicitation_style.py +7 -7
- fast_agent/ui/enhanced_prompt.py +21 -1
- fast_agent/ui/interactive_prompt.py +5 -0
- fast_agent/ui/mcp_display.py +708 -0
- {fast_agent_mcp-0.3.8.dist-info → fast_agent_mcp-0.3.10.dist-info}/METADATA +5 -5
- {fast_agent_mcp-0.3.8.dist-info → fast_agent_mcp-0.3.10.dist-info}/RECORD +24 -20
- {fast_agent_mcp-0.3.8.dist-info → fast_agent_mcp-0.3.10.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.8.dist-info → fast_agent_mcp-0.3.10.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.8.dist-info → fast_agent_mcp-0.3.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Rendering helpers for MCP status information in the enhanced prompt UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import TYPE_CHECKING, Iterable
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from fast_agent.ui import console
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from fast_agent.mcp.mcp_aggregator import ServerStatus
|
|
14
|
+
from fast_agent.mcp.transport_tracking import ChannelSnapshot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Centralized color configuration
|
|
18
|
+
class Colours:
|
|
19
|
+
"""Color constants for MCP status display elements."""
|
|
20
|
+
|
|
21
|
+
# Timeline activity colors (Option A: Mixed Intensity)
|
|
22
|
+
ERROR = "bright_red" # Keep error bright
|
|
23
|
+
DISABLED = "bright_blue" # Keep disabled bright
|
|
24
|
+
RESPONSE = "blue" # Normal blue instead of bright
|
|
25
|
+
REQUEST = "yellow" # Normal yellow instead of bright
|
|
26
|
+
NOTIFICATION = "cyan" # Normal cyan instead of bright
|
|
27
|
+
PING = "dim green" # Keep ping dim
|
|
28
|
+
IDLE = "white dim"
|
|
29
|
+
NONE = "dim"
|
|
30
|
+
|
|
31
|
+
# Channel arrow states
|
|
32
|
+
ARROW_ERROR = "bright_red"
|
|
33
|
+
ARROW_DISABLED = "bright_yellow" # For explicitly disabled/off
|
|
34
|
+
ARROW_METHOD_NOT_ALLOWED = "cyan" # For 405 method not allowed (notification color)
|
|
35
|
+
ARROW_OFF = "black dim"
|
|
36
|
+
ARROW_IDLE = "bright_cyan" # Connected but no activity
|
|
37
|
+
ARROW_ACTIVE = "bright_green" # Connected with activity
|
|
38
|
+
|
|
39
|
+
# Capability token states
|
|
40
|
+
TOKEN_ERROR = "bright_red"
|
|
41
|
+
TOKEN_WARNING = "bright_cyan"
|
|
42
|
+
TOKEN_DISABLED = "dim"
|
|
43
|
+
TOKEN_HIGHLIGHTED = "bright_yellow"
|
|
44
|
+
TOKEN_ENABLED = "bright_green"
|
|
45
|
+
|
|
46
|
+
# Text elements
|
|
47
|
+
TEXT_DIM = "dim"
|
|
48
|
+
TEXT_DEFAULT = "default" # Use terminal's default text color
|
|
49
|
+
TEXT_BRIGHT = "bright_white"
|
|
50
|
+
TEXT_ERROR = "bright_red"
|
|
51
|
+
TEXT_WARNING = "bright_yellow"
|
|
52
|
+
TEXT_SUCCESS = "bright_green"
|
|
53
|
+
TEXT_INFO = "bright_blue"
|
|
54
|
+
TEXT_CYAN = "cyan"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Color mappings for different contexts
|
|
58
|
+
TIMELINE_COLORS = {
|
|
59
|
+
"error": Colours.ERROR,
|
|
60
|
+
"disabled": Colours.DISABLED,
|
|
61
|
+
"response": Colours.RESPONSE,
|
|
62
|
+
"request": Colours.REQUEST,
|
|
63
|
+
"notification": Colours.NOTIFICATION,
|
|
64
|
+
"ping": Colours.PING,
|
|
65
|
+
"none": Colours.IDLE,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
TIMELINE_COLORS_STDIO = {
|
|
69
|
+
"error": Colours.ERROR,
|
|
70
|
+
"request": Colours.TOKEN_ENABLED, # All activity shows as bright green
|
|
71
|
+
"response": Colours.TOKEN_ENABLED,
|
|
72
|
+
"notification": Colours.TOKEN_ENABLED,
|
|
73
|
+
"ping": Colours.PING,
|
|
74
|
+
"none": Colours.IDLE,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _format_compact_duration(seconds: float | None) -> str | None:
|
|
79
|
+
if seconds is None:
|
|
80
|
+
return None
|
|
81
|
+
total = int(seconds)
|
|
82
|
+
if total < 1:
|
|
83
|
+
return "<1s"
|
|
84
|
+
mins, secs = divmod(total, 60)
|
|
85
|
+
if mins == 0:
|
|
86
|
+
return f"{secs}s"
|
|
87
|
+
hours, mins = divmod(mins, 60)
|
|
88
|
+
if hours == 0:
|
|
89
|
+
return f"{mins}m{secs:02d}s"
|
|
90
|
+
days, hours = divmod(hours, 24)
|
|
91
|
+
if days == 0:
|
|
92
|
+
return f"{hours}h{mins:02d}m"
|
|
93
|
+
return f"{days}d{hours:02d}h"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _summarise_call_counts(call_counts: dict[str, int]) -> str | None:
|
|
97
|
+
if not call_counts:
|
|
98
|
+
return None
|
|
99
|
+
ordered = sorted(call_counts.items(), key=lambda item: item[0])
|
|
100
|
+
return ", ".join(f"{name}:{count}" for name, count in ordered)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _format_session_id(session_id: str | None) -> Text:
|
|
104
|
+
text = Text()
|
|
105
|
+
if not session_id:
|
|
106
|
+
text.append("none", style="yellow")
|
|
107
|
+
return text
|
|
108
|
+
if session_id == "local":
|
|
109
|
+
text.append("local", style="cyan")
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
# Only trim if excessively long (>24 chars)
|
|
113
|
+
value = session_id
|
|
114
|
+
if len(session_id) > 24:
|
|
115
|
+
# Trim middle to preserve start and end
|
|
116
|
+
value = f"{session_id[:10]}...{session_id[-10:]}"
|
|
117
|
+
text.append(value, style="green")
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _build_aligned_field(
|
|
122
|
+
label: str, value: Text | str, *, label_width: int = 9, value_style: str = Colours.TEXT_DEFAULT
|
|
123
|
+
) -> Text:
|
|
124
|
+
field = Text()
|
|
125
|
+
field.append(f"{label:<{label_width}}: ", style="dim")
|
|
126
|
+
if isinstance(value, Text):
|
|
127
|
+
field.append_text(value)
|
|
128
|
+
else:
|
|
129
|
+
field.append(value, style=value_style)
|
|
130
|
+
return field
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cap_attr(source, attr: str | None) -> bool:
|
|
134
|
+
if source is None:
|
|
135
|
+
return False
|
|
136
|
+
target = source
|
|
137
|
+
if attr:
|
|
138
|
+
if isinstance(source, dict):
|
|
139
|
+
target = source.get(attr)
|
|
140
|
+
else:
|
|
141
|
+
target = getattr(source, attr, None)
|
|
142
|
+
if isinstance(target, bool):
|
|
143
|
+
return target
|
|
144
|
+
return bool(target)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _format_capability_shorthand(
|
|
148
|
+
status: ServerStatus, template_expected: bool
|
|
149
|
+
) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
|
|
150
|
+
caps = status.server_capabilities
|
|
151
|
+
tools = getattr(caps, "tools", None)
|
|
152
|
+
prompts = getattr(caps, "prompts", None)
|
|
153
|
+
resources = getattr(caps, "resources", None)
|
|
154
|
+
logging_caps = getattr(caps, "logging", None)
|
|
155
|
+
completion_caps = (
|
|
156
|
+
getattr(caps, "completion", None)
|
|
157
|
+
or getattr(caps, "completions", None)
|
|
158
|
+
or getattr(caps, "respond", None)
|
|
159
|
+
)
|
|
160
|
+
experimental_caps = getattr(caps, "experimental", None)
|
|
161
|
+
|
|
162
|
+
instructions_available = bool(status.instructions_available)
|
|
163
|
+
instructions_enabled = status.instructions_enabled
|
|
164
|
+
|
|
165
|
+
entries = [
|
|
166
|
+
("To", _cap_attr(tools, None), _cap_attr(tools, "listChanged")),
|
|
167
|
+
("Pr", _cap_attr(prompts, None), _cap_attr(prompts, "listChanged")),
|
|
168
|
+
(
|
|
169
|
+
"Re",
|
|
170
|
+
_cap_attr(resources, "read") or _cap_attr(resources, None),
|
|
171
|
+
_cap_attr(resources, "listChanged"),
|
|
172
|
+
),
|
|
173
|
+
("Rs", _cap_attr(resources, "subscribe"), _cap_attr(resources, "subscribe")),
|
|
174
|
+
("Lo", _cap_attr(logging_caps, None), False),
|
|
175
|
+
("Co", _cap_attr(completion_caps, None), _cap_attr(completion_caps, "listChanged")),
|
|
176
|
+
("Ex", _cap_attr(experimental_caps, None), False),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
if not instructions_available:
|
|
180
|
+
entries.append(("In", False, False))
|
|
181
|
+
elif instructions_enabled is False:
|
|
182
|
+
entries.append(("In", "red", False))
|
|
183
|
+
elif instructions_enabled is None and not template_expected:
|
|
184
|
+
entries.append(("In", "blue", False))
|
|
185
|
+
elif instructions_enabled is None:
|
|
186
|
+
entries.append(("In", True, False))
|
|
187
|
+
elif template_expected:
|
|
188
|
+
entries.append(("In", True, False))
|
|
189
|
+
else:
|
|
190
|
+
entries.append(("In", "blue", False))
|
|
191
|
+
|
|
192
|
+
if status.roots_configured:
|
|
193
|
+
entries.append(("Ro", True, False))
|
|
194
|
+
else:
|
|
195
|
+
entries.append(("Ro", False, False))
|
|
196
|
+
|
|
197
|
+
mode = (status.elicitation_mode or "").lower()
|
|
198
|
+
if mode == "auto_cancel":
|
|
199
|
+
entries.append(("El", "red", False))
|
|
200
|
+
elif mode and mode != "none":
|
|
201
|
+
entries.append(("El", True, False))
|
|
202
|
+
else:
|
|
203
|
+
entries.append(("El", False, False))
|
|
204
|
+
|
|
205
|
+
sampling_mode = (status.sampling_mode or "").lower()
|
|
206
|
+
if sampling_mode == "configured":
|
|
207
|
+
entries.append(("Sa", "blue", False))
|
|
208
|
+
elif sampling_mode == "auto":
|
|
209
|
+
entries.append(("Sa", True, False))
|
|
210
|
+
else:
|
|
211
|
+
entries.append(("Sa", False, False))
|
|
212
|
+
|
|
213
|
+
entries.append(("Sp", bool(status.spoofing_enabled), False))
|
|
214
|
+
|
|
215
|
+
def token_style(supported, highlighted) -> str:
|
|
216
|
+
if supported == "red":
|
|
217
|
+
return Colours.TOKEN_ERROR
|
|
218
|
+
if supported == "blue":
|
|
219
|
+
return Colours.TOKEN_WARNING
|
|
220
|
+
if not supported:
|
|
221
|
+
return Colours.TOKEN_DISABLED
|
|
222
|
+
if highlighted:
|
|
223
|
+
return Colours.TOKEN_HIGHLIGHTED
|
|
224
|
+
return Colours.TOKEN_ENABLED
|
|
225
|
+
|
|
226
|
+
tokens = [
|
|
227
|
+
(label, token_style(supported, highlighted)) for label, supported, highlighted in entries
|
|
228
|
+
]
|
|
229
|
+
return tokens[:8], tokens[8:]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_capability_text(tokens: list[tuple[str, str]]) -> Text:
|
|
233
|
+
line = Text()
|
|
234
|
+
host_boundary_inserted = False
|
|
235
|
+
for idx, (label, style) in enumerate(tokens):
|
|
236
|
+
if idx:
|
|
237
|
+
line.append(" ")
|
|
238
|
+
if not host_boundary_inserted and label == "Ro":
|
|
239
|
+
line.append("• ", style="dim")
|
|
240
|
+
host_boundary_inserted = True
|
|
241
|
+
line.append(label, style=style)
|
|
242
|
+
return line
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _format_relative_time(dt: datetime | None) -> str:
|
|
246
|
+
if dt is None:
|
|
247
|
+
return "never"
|
|
248
|
+
try:
|
|
249
|
+
now = datetime.now(timezone.utc)
|
|
250
|
+
except Exception:
|
|
251
|
+
now = datetime.utcnow().replace(tzinfo=timezone.utc)
|
|
252
|
+
seconds = max(0, (now - dt).total_seconds())
|
|
253
|
+
return _format_compact_duration(seconds) or "<1s"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _format_label(label: str, width: int = 10) -> str:
|
|
257
|
+
return f"{label:<{width}}" if len(label) < width else label
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _build_inline_timeline(buckets: Iterable[str]) -> str:
|
|
261
|
+
"""Build a compact timeline string for inline display."""
|
|
262
|
+
timeline = " [dim]10m[/dim] "
|
|
263
|
+
for state in buckets:
|
|
264
|
+
color = TIMELINE_COLORS.get(state, Colours.NONE)
|
|
265
|
+
if state in {"idle", "none"}:
|
|
266
|
+
symbol = "·"
|
|
267
|
+
elif state == "request":
|
|
268
|
+
symbol = "◆" # Diamond for requests - rare and important
|
|
269
|
+
else:
|
|
270
|
+
symbol = "●" # Circle for other activity
|
|
271
|
+
timeline += f"[bold {color}]{symbol}[/bold {color}]"
|
|
272
|
+
timeline += " [dim]now[/dim]"
|
|
273
|
+
return timeline
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _render_channel_summary(status: ServerStatus, indent: str, total_width: int) -> None:
|
|
277
|
+
snapshot = getattr(status, "transport_channels", None)
|
|
278
|
+
if snapshot is None:
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
# Show channel types based on what's available
|
|
282
|
+
entries: list[tuple[str, str, ChannelSnapshot | None]] = []
|
|
283
|
+
|
|
284
|
+
# Check if we have HTTP transport channels
|
|
285
|
+
http_channels = [
|
|
286
|
+
getattr(snapshot, "get", None),
|
|
287
|
+
getattr(snapshot, "post_sse", None),
|
|
288
|
+
getattr(snapshot, "post_json", None),
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
# Check if we have stdio transport channel
|
|
292
|
+
stdio_channel = getattr(snapshot, "stdio", None)
|
|
293
|
+
|
|
294
|
+
if any(channel is not None for channel in http_channels):
|
|
295
|
+
# HTTP transport - show the original three channels
|
|
296
|
+
entries = [
|
|
297
|
+
("GET (SSE)", "◀", getattr(snapshot, "get", None)),
|
|
298
|
+
("POST (SSE)", "▶", getattr(snapshot, "post_sse", None)),
|
|
299
|
+
("POST (JSON)", "▶", getattr(snapshot, "post_json", None)),
|
|
300
|
+
]
|
|
301
|
+
elif stdio_channel is not None:
|
|
302
|
+
# STDIO transport - show single bidirectional channel
|
|
303
|
+
entries = [
|
|
304
|
+
("STDIO", "⇄", stdio_channel),
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
# Skip if no channels have data
|
|
308
|
+
if not any(channel is not None for _, _, channel in entries):
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
console.console.print() # Add space before channels
|
|
312
|
+
|
|
313
|
+
# Determine if we're showing stdio or HTTP channels
|
|
314
|
+
is_stdio = stdio_channel is not None
|
|
315
|
+
|
|
316
|
+
# Get transport type for display
|
|
317
|
+
transport = getattr(status, "transport", None) or "unknown"
|
|
318
|
+
transport_display = transport.upper() if transport != "unknown" else "Channels"
|
|
319
|
+
|
|
320
|
+
# Header with column labels
|
|
321
|
+
header = Text(indent)
|
|
322
|
+
header.append(f"┌ {transport_display} ", style="dim")
|
|
323
|
+
|
|
324
|
+
# Calculate padding needed based on transport display length
|
|
325
|
+
# Base structure: "┌ " (2) + transport_display + " " (1) + "─" padding to align with columns
|
|
326
|
+
header_prefix_len = 3 + len(transport_display)
|
|
327
|
+
|
|
328
|
+
if is_stdio:
|
|
329
|
+
# Simplified header for stdio: just activity column
|
|
330
|
+
# Need to align with "│ ⇄ STDIO 10m ●●●●●●●●●●●●●●●●●●●● now 29"
|
|
331
|
+
# That's: "│ " + arrow + " " + label(13) + "10m " + dots(20) + " now" = 47 chars
|
|
332
|
+
# Then: " " + activity(8) = 10 chars
|
|
333
|
+
# Total content width = 47 + 10 = 57 chars
|
|
334
|
+
# So we need 47 - header_prefix_len dashes before "activity"
|
|
335
|
+
dash_count = max(1, 47 - header_prefix_len)
|
|
336
|
+
header.append("─" * dash_count, style="dim")
|
|
337
|
+
header.append(" activity", style="dim")
|
|
338
|
+
else:
|
|
339
|
+
# Original header for HTTP channels
|
|
340
|
+
# Need to align with the req/resp/notif/ping columns
|
|
341
|
+
# Structure: "│ " + arrow + " " + label(13) + "10m " + dots(20) + " now" = 47 chars
|
|
342
|
+
# Then: " " + req(5) + " " + resp(5) + " " + notif(5) + " " + ping(5) = 25 chars
|
|
343
|
+
# Total content width = 47 + 25 = 72 chars
|
|
344
|
+
# So we need 47 - header_prefix_len dashes before the column headers
|
|
345
|
+
dash_count = max(1, 47 - header_prefix_len)
|
|
346
|
+
header.append("─" * dash_count, style="dim")
|
|
347
|
+
header.append(" req resp notif ping", style="dim")
|
|
348
|
+
|
|
349
|
+
console.console.print(header)
|
|
350
|
+
|
|
351
|
+
# Empty row after header for cleaner spacing
|
|
352
|
+
empty_header = Text(indent)
|
|
353
|
+
empty_header.append("│", style="dim")
|
|
354
|
+
console.console.print(empty_header)
|
|
355
|
+
|
|
356
|
+
# Collect any errors to show at bottom
|
|
357
|
+
errors = []
|
|
358
|
+
|
|
359
|
+
# Get appropriate timeline color map
|
|
360
|
+
timeline_color_map = TIMELINE_COLORS_STDIO if is_stdio else TIMELINE_COLORS
|
|
361
|
+
|
|
362
|
+
for label, arrow, channel in entries:
|
|
363
|
+
line = Text(indent)
|
|
364
|
+
line.append("│ ", style="dim")
|
|
365
|
+
|
|
366
|
+
# Determine arrow color based on state
|
|
367
|
+
arrow_style = Colours.ARROW_OFF # default no channel
|
|
368
|
+
if channel:
|
|
369
|
+
state = (channel.state or "open").lower()
|
|
370
|
+
|
|
371
|
+
# Check for 405 status code (method not allowed = not an error, just unsupported)
|
|
372
|
+
if channel.last_status_code == 405:
|
|
373
|
+
arrow_style = Colours.ARROW_METHOD_NOT_ALLOWED
|
|
374
|
+
# Don't add 405 to errors list - it's not an error, just method not supported
|
|
375
|
+
# Error state (non-405 errors)
|
|
376
|
+
elif state == "error":
|
|
377
|
+
arrow_style = Colours.ARROW_ERROR
|
|
378
|
+
if channel.last_error and channel.last_status_code != 405:
|
|
379
|
+
error_msg = channel.last_error
|
|
380
|
+
if channel.last_status_code:
|
|
381
|
+
errors.append(
|
|
382
|
+
(label.split()[0], f"{error_msg} ({channel.last_status_code})")
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
errors.append((label.split()[0], error_msg))
|
|
386
|
+
# Explicitly disabled or off
|
|
387
|
+
elif state in {"off", "disabled"}:
|
|
388
|
+
arrow_style = Colours.ARROW_OFF
|
|
389
|
+
# No activity (idle)
|
|
390
|
+
elif channel.request_count == 0 and channel.response_count == 0:
|
|
391
|
+
arrow_style = Colours.ARROW_IDLE
|
|
392
|
+
# Active/connected with activity
|
|
393
|
+
elif state in {"open", "connected"}:
|
|
394
|
+
arrow_style = Colours.ARROW_ACTIVE
|
|
395
|
+
# Fallback for other states
|
|
396
|
+
else:
|
|
397
|
+
arrow_style = Colours.ARROW_IDLE
|
|
398
|
+
|
|
399
|
+
# Arrow and label with better spacing
|
|
400
|
+
# Use hollow arrow for 405 Method Not Allowed
|
|
401
|
+
if channel and channel.last_status_code == 405:
|
|
402
|
+
# Convert solid arrows to hollow for 405
|
|
403
|
+
hollow_arrows = {"◀": "◁", "▶": "▷", "⇄": "⇄"} # bidirectional stays same
|
|
404
|
+
display_arrow = hollow_arrows.get(arrow, arrow)
|
|
405
|
+
else:
|
|
406
|
+
display_arrow = arrow
|
|
407
|
+
line.append(display_arrow, style=arrow_style)
|
|
408
|
+
line.append(f" {label:<13}", style=Colours.TEXT_DEFAULT)
|
|
409
|
+
|
|
410
|
+
# Always show timeline (dim black dots if no data)
|
|
411
|
+
line.append("10m ", style="dim")
|
|
412
|
+
if channel and channel.activity_buckets:
|
|
413
|
+
# Show actual activity
|
|
414
|
+
for bucket_state in channel.activity_buckets:
|
|
415
|
+
color = timeline_color_map.get(bucket_state, "dim")
|
|
416
|
+
if bucket_state in {"idle", "none"}:
|
|
417
|
+
symbol = "·"
|
|
418
|
+
elif bucket_state == "request":
|
|
419
|
+
symbol = "◆" # Diamond for requests - rare and important
|
|
420
|
+
else:
|
|
421
|
+
symbol = "●" # Circle for other activity
|
|
422
|
+
line.append(symbol, style=f"bold {color}")
|
|
423
|
+
else:
|
|
424
|
+
# Show dim dots for no activity
|
|
425
|
+
for _ in range(20):
|
|
426
|
+
line.append("·", style="black dim")
|
|
427
|
+
line.append(" now", style="dim")
|
|
428
|
+
|
|
429
|
+
# Metrics - different layouts for stdio vs HTTP
|
|
430
|
+
if is_stdio:
|
|
431
|
+
# Simplified activity column for stdio
|
|
432
|
+
if channel and channel.message_count > 0:
|
|
433
|
+
activity = str(channel.message_count).rjust(8)
|
|
434
|
+
activity_style = Colours.TEXT_DEFAULT
|
|
435
|
+
else:
|
|
436
|
+
activity = "-".rjust(8)
|
|
437
|
+
activity_style = Colours.TEXT_DIM
|
|
438
|
+
line.append(f" {activity}", style=activity_style)
|
|
439
|
+
else:
|
|
440
|
+
# Original HTTP columns
|
|
441
|
+
if channel:
|
|
442
|
+
# Show "-" for shut/disabled channels (405, off, disabled states)
|
|
443
|
+
channel_state = (channel.state or "open").lower()
|
|
444
|
+
is_shut = (
|
|
445
|
+
channel.last_status_code == 405 or
|
|
446
|
+
channel_state in {"off", "disabled"} or
|
|
447
|
+
(channel_state == "error" and channel.last_status_code == 405)
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if is_shut:
|
|
451
|
+
req = "-".rjust(5)
|
|
452
|
+
resp = "-".rjust(5)
|
|
453
|
+
notif = "-".rjust(5)
|
|
454
|
+
ping = "-".rjust(5)
|
|
455
|
+
metrics_style = Colours.TEXT_DIM
|
|
456
|
+
else:
|
|
457
|
+
req = str(channel.request_count).rjust(5)
|
|
458
|
+
resp = str(channel.response_count).rjust(5)
|
|
459
|
+
notif = str(channel.notification_count).rjust(5)
|
|
460
|
+
ping = str(channel.ping_count if channel.ping_count else "-").rjust(5)
|
|
461
|
+
metrics_style = Colours.TEXT_DEFAULT
|
|
462
|
+
else:
|
|
463
|
+
req = "-".rjust(5)
|
|
464
|
+
resp = "-".rjust(5)
|
|
465
|
+
notif = "-".rjust(5)
|
|
466
|
+
ping = "-".rjust(5)
|
|
467
|
+
metrics_style = Colours.TEXT_DIM
|
|
468
|
+
line.append(f" {req} {resp} {notif} {ping}", style=metrics_style)
|
|
469
|
+
|
|
470
|
+
console.console.print(line)
|
|
471
|
+
|
|
472
|
+
# Debug: print the raw line length
|
|
473
|
+
# import sys
|
|
474
|
+
# print(f"Line length: {len(line.plain)}", file=sys.stderr)
|
|
475
|
+
|
|
476
|
+
# Show errors at bottom if any
|
|
477
|
+
if errors:
|
|
478
|
+
# Empty row before errors
|
|
479
|
+
empty_line = Text(indent)
|
|
480
|
+
empty_line.append("│", style="dim")
|
|
481
|
+
console.console.print(empty_line)
|
|
482
|
+
|
|
483
|
+
for channel_type, error_msg in errors:
|
|
484
|
+
error_line = Text(indent)
|
|
485
|
+
error_line.append("│ ", style=Colours.TEXT_DIM)
|
|
486
|
+
error_line.append("⚠ ", style=Colours.TEXT_WARNING)
|
|
487
|
+
error_line.append(f"{channel_type}: ", style=Colours.TEXT_DEFAULT)
|
|
488
|
+
# Truncate long error messages
|
|
489
|
+
if len(error_msg) > 60:
|
|
490
|
+
error_msg = error_msg[:57] + "..."
|
|
491
|
+
error_line.append(error_msg, style=Colours.TEXT_ERROR)
|
|
492
|
+
console.console.print(error_line)
|
|
493
|
+
|
|
494
|
+
# Legend if any timelines shown
|
|
495
|
+
has_timelines = any(channel and channel.activity_buckets for _, _, channel in entries)
|
|
496
|
+
|
|
497
|
+
if has_timelines:
|
|
498
|
+
# Empty row before footer with legend
|
|
499
|
+
empty_before = Text(indent)
|
|
500
|
+
empty_before.append("│", style="dim")
|
|
501
|
+
console.console.print(empty_before)
|
|
502
|
+
|
|
503
|
+
# Footer with legend
|
|
504
|
+
footer = Text(indent)
|
|
505
|
+
footer.append("└", style="dim")
|
|
506
|
+
|
|
507
|
+
if has_timelines:
|
|
508
|
+
footer.append(" legend: ", style="dim")
|
|
509
|
+
|
|
510
|
+
if is_stdio:
|
|
511
|
+
# Simplified legend for stdio: just activity vs idle
|
|
512
|
+
legend_map = [
|
|
513
|
+
("activity", f"bold {Colours.TOKEN_ENABLED}"),
|
|
514
|
+
("idle", Colours.IDLE),
|
|
515
|
+
]
|
|
516
|
+
else:
|
|
517
|
+
# Full legend for HTTP channels
|
|
518
|
+
legend_map = [
|
|
519
|
+
("error", f"bold {Colours.ERROR}"),
|
|
520
|
+
("response", f"bold {Colours.RESPONSE}"),
|
|
521
|
+
("request", f"bold {Colours.REQUEST}"),
|
|
522
|
+
("notification", f"bold {Colours.NOTIFICATION}"),
|
|
523
|
+
("ping", Colours.PING),
|
|
524
|
+
("idle", Colours.IDLE),
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
for i, (name, color) in enumerate(legend_map):
|
|
528
|
+
if i > 0:
|
|
529
|
+
footer.append(" ", style="dim")
|
|
530
|
+
if name == "idle":
|
|
531
|
+
symbol = "·"
|
|
532
|
+
elif name == "request":
|
|
533
|
+
symbol = "◆" # Diamond for requests
|
|
534
|
+
else:
|
|
535
|
+
symbol = "●"
|
|
536
|
+
footer.append(symbol, style=f"{color}")
|
|
537
|
+
footer.append(f" {name}", style="dim")
|
|
538
|
+
|
|
539
|
+
console.console.print(footer)
|
|
540
|
+
|
|
541
|
+
# Add blank line for spacing before capabilities
|
|
542
|
+
console.console.print()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
async def render_mcp_status(agent, indent: str = "") -> None:
|
|
546
|
+
server_status_map = {}
|
|
547
|
+
if hasattr(agent, "get_server_status") and callable(getattr(agent, "get_server_status")):
|
|
548
|
+
try:
|
|
549
|
+
server_status_map = await agent.get_server_status()
|
|
550
|
+
except Exception:
|
|
551
|
+
server_status_map = {}
|
|
552
|
+
|
|
553
|
+
if not server_status_map:
|
|
554
|
+
console.console.print(f"{indent}[dim]•[/dim] [dim]No MCP status available[/dim]")
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
template_expected = False
|
|
558
|
+
if hasattr(agent, "config"):
|
|
559
|
+
template_expected = "{{serverInstructions}}" in str(
|
|
560
|
+
getattr(agent.config, "instruction", "")
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
total_width = console.console.size.width
|
|
565
|
+
except Exception:
|
|
566
|
+
total_width = 80
|
|
567
|
+
|
|
568
|
+
def render_header(label: Text, right: Text | None = None) -> None:
|
|
569
|
+
line = Text()
|
|
570
|
+
line.append_text(label)
|
|
571
|
+
line.append(" ")
|
|
572
|
+
|
|
573
|
+
separator_width = total_width - line.cell_len
|
|
574
|
+
if right and right.cell_len > 0:
|
|
575
|
+
separator_width -= right.cell_len
|
|
576
|
+
separator_width = max(1, separator_width)
|
|
577
|
+
line.append("─" * separator_width, style="dim")
|
|
578
|
+
line.append_text(right)
|
|
579
|
+
else:
|
|
580
|
+
line.append("─" * max(1, separator_width), style="dim")
|
|
581
|
+
|
|
582
|
+
console.console.print()
|
|
583
|
+
console.console.print(line)
|
|
584
|
+
console.console.print()
|
|
585
|
+
|
|
586
|
+
server_items = list(sorted(server_status_map.items()))
|
|
587
|
+
|
|
588
|
+
for index, (server, status) in enumerate(server_items, start=1):
|
|
589
|
+
primary_caps, secondary_caps = _format_capability_shorthand(status, template_expected)
|
|
590
|
+
|
|
591
|
+
impl_name = status.implementation_name or status.server_name or "unknown"
|
|
592
|
+
impl_display = impl_name[:30]
|
|
593
|
+
if len(impl_name) > 30:
|
|
594
|
+
impl_display = impl_display[:27] + "..."
|
|
595
|
+
|
|
596
|
+
version_display = status.implementation_version or ""
|
|
597
|
+
if len(version_display) > 12:
|
|
598
|
+
version_display = version_display[:9] + "..."
|
|
599
|
+
|
|
600
|
+
header_label = Text(indent)
|
|
601
|
+
header_label.append("▎", style=Colours.TEXT_CYAN)
|
|
602
|
+
header_label.append("●", style=f"dim {Colours.TEXT_CYAN}")
|
|
603
|
+
header_label.append(f" [{index:2}] ", style=Colours.TEXT_CYAN)
|
|
604
|
+
header_label.append(server, style=f"{Colours.TEXT_INFO} bold")
|
|
605
|
+
render_header(header_label)
|
|
606
|
+
|
|
607
|
+
# First line: name and version
|
|
608
|
+
meta_line = Text(indent + " ")
|
|
609
|
+
meta_fields: list[Text] = []
|
|
610
|
+
meta_fields.append(_build_aligned_field("name", impl_display))
|
|
611
|
+
if version_display:
|
|
612
|
+
meta_fields.append(_build_aligned_field("version", version_display))
|
|
613
|
+
|
|
614
|
+
for idx, field in enumerate(meta_fields):
|
|
615
|
+
if idx:
|
|
616
|
+
meta_line.append(" ", style="dim")
|
|
617
|
+
meta_line.append_text(field)
|
|
618
|
+
|
|
619
|
+
client_parts = []
|
|
620
|
+
if status.client_info_name:
|
|
621
|
+
client_parts.append(status.client_info_name)
|
|
622
|
+
if status.client_info_version:
|
|
623
|
+
client_parts.append(status.client_info_version)
|
|
624
|
+
client_display = " ".join(client_parts)
|
|
625
|
+
if len(client_display) > 24:
|
|
626
|
+
client_display = client_display[:21] + "..."
|
|
627
|
+
|
|
628
|
+
if client_display:
|
|
629
|
+
meta_line.append(" | ", style="dim")
|
|
630
|
+
meta_line.append_text(_build_aligned_field("client", client_display))
|
|
631
|
+
|
|
632
|
+
console.console.print(meta_line)
|
|
633
|
+
|
|
634
|
+
# Second line: session (on its own line)
|
|
635
|
+
session_line = Text(indent + " ")
|
|
636
|
+
session_text = _format_session_id(status.session_id)
|
|
637
|
+
session_line.append_text(_build_aligned_field("session", session_text))
|
|
638
|
+
console.console.print(session_line)
|
|
639
|
+
console.console.print()
|
|
640
|
+
|
|
641
|
+
# Build status segments
|
|
642
|
+
state_segments: list[Text] = []
|
|
643
|
+
|
|
644
|
+
duration = _format_compact_duration(status.staleness_seconds)
|
|
645
|
+
if duration:
|
|
646
|
+
last_text = Text("last activity: ", style=Colours.TEXT_DIM)
|
|
647
|
+
last_text.append(duration, style=Colours.TEXT_DEFAULT)
|
|
648
|
+
last_text.append(" ago", style=Colours.TEXT_DIM)
|
|
649
|
+
state_segments.append(last_text)
|
|
650
|
+
|
|
651
|
+
if status.error_message and status.is_connected is False:
|
|
652
|
+
state_segments.append(Text(status.error_message, style=Colours.TEXT_ERROR))
|
|
653
|
+
|
|
654
|
+
instr_available = bool(status.instructions_available)
|
|
655
|
+
if instr_available and status.instructions_enabled is False:
|
|
656
|
+
state_segments.append(Text("instructions disabled", style=Colours.TEXT_ERROR))
|
|
657
|
+
elif instr_available and not template_expected:
|
|
658
|
+
state_segments.append(Text("template missing", style=Colours.TEXT_WARNING))
|
|
659
|
+
|
|
660
|
+
if status.spoofing_enabled:
|
|
661
|
+
state_segments.append(Text("client spoof", style=Colours.TEXT_WARNING))
|
|
662
|
+
|
|
663
|
+
# Main status line (without transport and connected)
|
|
664
|
+
if state_segments:
|
|
665
|
+
status_line = Text(indent + " ")
|
|
666
|
+
for idx, segment in enumerate(state_segments):
|
|
667
|
+
if idx:
|
|
668
|
+
status_line.append(" | ", style="dim")
|
|
669
|
+
status_line.append_text(segment)
|
|
670
|
+
console.console.print(status_line)
|
|
671
|
+
|
|
672
|
+
# MCP protocol calls made (only shows calls that have actually been invoked)
|
|
673
|
+
calls = _summarise_call_counts(status.call_counts)
|
|
674
|
+
if calls:
|
|
675
|
+
calls_line = Text(indent + " ")
|
|
676
|
+
calls_line.append("mcp calls: ", style=Colours.TEXT_DIM)
|
|
677
|
+
calls_line.append(calls, style=Colours.TEXT_DEFAULT)
|
|
678
|
+
console.console.print(calls_line)
|
|
679
|
+
_render_channel_summary(status, indent, total_width)
|
|
680
|
+
|
|
681
|
+
combined_tokens = primary_caps + secondary_caps
|
|
682
|
+
prefix = Text(indent)
|
|
683
|
+
prefix.append("─| ", style="dim")
|
|
684
|
+
suffix = Text(" |", style="dim")
|
|
685
|
+
|
|
686
|
+
caps_content = (
|
|
687
|
+
_build_capability_text(combined_tokens)
|
|
688
|
+
if combined_tokens
|
|
689
|
+
else Text("none", style="dim")
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
caps_display = caps_content.copy()
|
|
693
|
+
available = max(0, total_width - prefix.cell_len - suffix.cell_len)
|
|
694
|
+
if caps_display.cell_len > available:
|
|
695
|
+
caps_display.truncate(available)
|
|
696
|
+
|
|
697
|
+
banner_line = Text()
|
|
698
|
+
banner_line.append_text(prefix)
|
|
699
|
+
banner_line.append_text(caps_display)
|
|
700
|
+
banner_line.append_text(suffix)
|
|
701
|
+
remaining = total_width - banner_line.cell_len
|
|
702
|
+
if remaining > 0:
|
|
703
|
+
banner_line.append("─" * remaining, style="dim")
|
|
704
|
+
|
|
705
|
+
console.console.print(banner_line)
|
|
706
|
+
|
|
707
|
+
if index != len(server_items):
|
|
708
|
+
console.console.print()
|