fast-agent-mcp 0.3.13__py3-none-any.whl → 0.3.15__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.

Files changed (44) hide show
  1. fast_agent/agents/llm_agent.py +59 -37
  2. fast_agent/agents/llm_decorator.py +13 -2
  3. fast_agent/agents/mcp_agent.py +21 -5
  4. fast_agent/agents/tool_agent.py +41 -29
  5. fast_agent/agents/workflow/router_agent.py +2 -1
  6. fast_agent/cli/commands/check_config.py +48 -1
  7. fast_agent/config.py +65 -2
  8. fast_agent/constants.py +3 -0
  9. fast_agent/context.py +42 -9
  10. fast_agent/core/fastagent.py +14 -1
  11. fast_agent/core/logging/listeners.py +1 -1
  12. fast_agent/core/validation.py +31 -33
  13. fast_agent/event_progress.py +2 -3
  14. fast_agent/human_input/form_fields.py +4 -1
  15. fast_agent/interfaces.py +12 -2
  16. fast_agent/llm/fastagent_llm.py +31 -0
  17. fast_agent/llm/model_database.py +2 -2
  18. fast_agent/llm/model_factory.py +8 -1
  19. fast_agent/llm/provider_key_manager.py +1 -0
  20. fast_agent/llm/provider_types.py +1 -0
  21. fast_agent/llm/request_params.py +3 -1
  22. fast_agent/mcp/mcp_aggregator.py +313 -40
  23. fast_agent/mcp/mcp_connection_manager.py +39 -9
  24. fast_agent/mcp/prompt_message_extended.py +2 -2
  25. fast_agent/mcp/skybridge.py +45 -0
  26. fast_agent/mcp/sse_tracking.py +287 -0
  27. fast_agent/mcp/transport_tracking.py +37 -3
  28. fast_agent/mcp/types.py +24 -0
  29. fast_agent/resources/examples/workflows/router.py +1 -0
  30. fast_agent/resources/setup/fastagent.config.yaml +7 -1
  31. fast_agent/ui/console_display.py +946 -84
  32. fast_agent/ui/elicitation_form.py +23 -1
  33. fast_agent/ui/enhanced_prompt.py +153 -58
  34. fast_agent/ui/interactive_prompt.py +57 -34
  35. fast_agent/ui/markdown_truncator.py +942 -0
  36. fast_agent/ui/mcp_display.py +110 -29
  37. fast_agent/ui/plain_text_truncator.py +68 -0
  38. fast_agent/ui/rich_progress.py +4 -1
  39. fast_agent/ui/streaming_buffer.py +449 -0
  40. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/METADATA +4 -3
  41. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/RECORD +44 -38
  42. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/WHEEL +0 -0
  43. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/entry_points.txt +0 -0
  44. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,287 @@
1
+ """SSE transport wrapper that emits channel events for UI display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable
8
+ from urllib.parse import parse_qs, urljoin, urlparse
9
+
10
+ import anyio
11
+ import httpx
12
+ import mcp.types as types
13
+ from httpx_sse import aconnect_sse
14
+ from httpx_sse._exceptions import SSEError
15
+ from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
16
+ from mcp.shared.message import SessionMessage
17
+
18
+ from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName
19
+
20
+ if TYPE_CHECKING:
21
+ from anyio.abc import TaskStatus
22
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ ChannelHook = Callable[[ChannelEvent], None]
27
+
28
+
29
+ def _extract_session_id(endpoint_url: str) -> str | None:
30
+ parsed = urlparse(endpoint_url)
31
+ query_params = parse_qs(parsed.query)
32
+ for key in ("sessionId", "session_id", "session"):
33
+ values = query_params.get(key)
34
+ if values:
35
+ return values[0]
36
+ return None
37
+
38
+
39
+ def _emit_channel_event(
40
+ channel_hook: ChannelHook | None,
41
+ channel: ChannelName,
42
+ event_type: str,
43
+ *,
44
+ message: types.JSONRPCMessage | None = None,
45
+ raw_event: str | None = None,
46
+ detail: str | None = None,
47
+ status_code: int | None = None,
48
+ ) -> None:
49
+ if channel_hook is None:
50
+ return
51
+ try:
52
+ channel_hook(
53
+ ChannelEvent(
54
+ channel=channel,
55
+ event_type=event_type, # type: ignore[arg-type]
56
+ message=message,
57
+ raw_event=raw_event,
58
+ detail=detail,
59
+ status_code=status_code,
60
+ )
61
+ )
62
+ except Exception:
63
+ logger.debug("Channel hook raised an exception", exc_info=True)
64
+
65
+
66
+ def _format_http_error(exc: httpx.HTTPStatusError) -> tuple[int | None, str]:
67
+ status_code: int | None = None
68
+ detail = str(exc)
69
+ if exc.response is not None:
70
+ status_code = exc.response.status_code
71
+ reason = exc.response.reason_phrase or ""
72
+ if not reason:
73
+ try:
74
+ reason = (exc.response.text or "").strip()
75
+ except Exception:
76
+ reason = ""
77
+ detail = f"HTTP {status_code}: {reason or 'response'}"
78
+ return status_code, detail
79
+
80
+
81
+ @asynccontextmanager
82
+ async def tracking_sse_client(
83
+ url: str,
84
+ headers: dict[str, Any] | None = None,
85
+ timeout: float = 5,
86
+ sse_read_timeout: float = 60 * 5,
87
+ httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
88
+ auth: httpx.Auth | None = None,
89
+ channel_hook: ChannelHook | None = None,
90
+ ) -> AsyncGenerator[
91
+ tuple[
92
+ MemoryObjectReceiveStream[SessionMessage | Exception],
93
+ MemoryObjectSendStream[SessionMessage],
94
+ Callable[[], str | None],
95
+ ],
96
+ None,
97
+ ]:
98
+ """
99
+ Client transport for SSE with channel activity tracking.
100
+ """
101
+
102
+ read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](
103
+ 0
104
+ )
105
+ write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
106
+
107
+ session_id: str | None = None
108
+
109
+ def get_session_id() -> str | None:
110
+ return session_id
111
+
112
+ async with anyio.create_task_group() as tg:
113
+ try:
114
+ logger.debug("Connecting to SSE endpoint: %s", url)
115
+ async with httpx_client_factory(
116
+ headers=headers,
117
+ auth=auth,
118
+ timeout=httpx.Timeout(timeout, read=sse_read_timeout),
119
+ ) as client:
120
+ connected = False
121
+ post_connected = False
122
+
123
+ async def sse_reader(
124
+ task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
125
+ ):
126
+ try:
127
+ async for sse in event_source.aiter_sse():
128
+ if sse.event == "endpoint":
129
+ endpoint_url = urljoin(url, sse.data)
130
+ logger.debug("Received SSE endpoint URL: %s", endpoint_url)
131
+
132
+ url_parsed = urlparse(url)
133
+ endpoint_parsed = urlparse(endpoint_url)
134
+ if (
135
+ url_parsed.scheme != endpoint_parsed.scheme
136
+ or url_parsed.netloc != endpoint_parsed.netloc
137
+ ):
138
+ error_msg = (
139
+ "Endpoint origin does not match connection origin: "
140
+ f"{endpoint_url}"
141
+ )
142
+ logger.error(error_msg)
143
+ _emit_channel_event(
144
+ channel_hook,
145
+ "get",
146
+ "error",
147
+ detail=error_msg,
148
+ )
149
+ raise ValueError(error_msg)
150
+
151
+ nonlocal session_id
152
+ session_id = _extract_session_id(endpoint_url)
153
+ task_status.started(endpoint_url)
154
+ elif sse.event == "message":
155
+ try:
156
+ message = types.JSONRPCMessage.model_validate_json(sse.data)
157
+ except Exception as exc:
158
+ logger.exception("Error parsing server message")
159
+ _emit_channel_event(
160
+ channel_hook,
161
+ "get",
162
+ "error",
163
+ detail="Error parsing server message",
164
+ )
165
+ await read_stream_writer.send(exc)
166
+ continue
167
+
168
+ _emit_channel_event(channel_hook, "get", "message", message=message)
169
+ await read_stream_writer.send(SessionMessage(message))
170
+ else:
171
+ _emit_channel_event(
172
+ channel_hook,
173
+ "get",
174
+ "keepalive",
175
+ raw_event=sse.event or "keepalive",
176
+ )
177
+ except SSEError as sse_exc:
178
+ logger.exception("Encountered SSE exception")
179
+ _emit_channel_event(
180
+ channel_hook,
181
+ "get",
182
+ "error",
183
+ detail=str(sse_exc),
184
+ )
185
+ raise
186
+ except Exception as exc:
187
+ logger.exception("Error in sse_reader")
188
+ _emit_channel_event(
189
+ channel_hook,
190
+ "get",
191
+ "error",
192
+ detail=str(exc),
193
+ )
194
+ await read_stream_writer.send(exc)
195
+ finally:
196
+ await read_stream_writer.aclose()
197
+
198
+ async def post_writer(endpoint_url: str):
199
+ try:
200
+ async with write_stream_reader:
201
+ async for session_message in write_stream_reader:
202
+ try:
203
+ payload = session_message.message.model_dump(
204
+ by_alias=True,
205
+ mode="json",
206
+ exclude_none=True,
207
+ )
208
+ except Exception:
209
+ logger.exception("Invalid session message payload")
210
+ continue
211
+
212
+ _emit_channel_event(
213
+ channel_hook,
214
+ "post-sse",
215
+ "message",
216
+ message=session_message.message,
217
+ )
218
+
219
+ try:
220
+ response = await client.post(endpoint_url, json=payload)
221
+ response.raise_for_status()
222
+ except httpx.HTTPStatusError as exc:
223
+ status_code, detail = _format_http_error(exc)
224
+ _emit_channel_event(
225
+ channel_hook,
226
+ "post-sse",
227
+ "error",
228
+ detail=detail,
229
+ status_code=status_code,
230
+ )
231
+ raise
232
+ except httpx.HTTPStatusError:
233
+ logger.exception("HTTP error in post_writer")
234
+ except Exception:
235
+ logger.exception("Error in post_writer")
236
+ _emit_channel_event(
237
+ channel_hook,
238
+ "post-sse",
239
+ "error",
240
+ detail="Error sending client message",
241
+ )
242
+ finally:
243
+ await write_stream.aclose()
244
+
245
+ try:
246
+ async with aconnect_sse(
247
+ client,
248
+ "GET",
249
+ url,
250
+ ) as event_source:
251
+ try:
252
+ event_source.response.raise_for_status()
253
+ except httpx.HTTPStatusError as exc:
254
+ status_code, detail = _format_http_error(exc)
255
+ _emit_channel_event(
256
+ channel_hook,
257
+ "get",
258
+ "error",
259
+ detail=detail,
260
+ status_code=status_code,
261
+ )
262
+ raise
263
+
264
+ _emit_channel_event(channel_hook, "get", "connect")
265
+ connected = True
266
+
267
+ endpoint_url = await tg.start(sse_reader)
268
+ _emit_channel_event(channel_hook, "post-sse", "connect")
269
+ post_connected = True
270
+ tg.start_soon(post_writer, endpoint_url)
271
+
272
+ try:
273
+ yield read_stream, write_stream, get_session_id
274
+ finally:
275
+ tg.cancel_scope.cancel()
276
+ except Exception:
277
+ raise
278
+ finally:
279
+ if connected:
280
+ _emit_channel_event(channel_hook, "get", "disconnect")
281
+ if post_connected:
282
+ _emit_channel_event(channel_hook, "post-sse", "disconnect")
283
+ finally:
284
+ await read_stream_writer.aclose()
285
+ await read_stream.aclose()
286
+ await write_stream_reader.aclose()
287
+ await write_stream.aclose()
@@ -82,6 +82,8 @@ class ChannelSnapshot(BaseModel):
82
82
  response_count: int = 0
83
83
  notification_count: int = 0
84
84
  activity_buckets: list[str] | None = None
85
+ activity_bucket_seconds: int | None = None
86
+ activity_bucket_count: int | None = None
85
87
 
86
88
 
87
89
  class TransportSnapshot(BaseModel):
@@ -95,12 +97,18 @@ class TransportSnapshot(BaseModel):
95
97
  get: ChannelSnapshot | None = None
96
98
  resumption: ChannelSnapshot | None = None
97
99
  stdio: ChannelSnapshot | None = None
100
+ activity_bucket_seconds: int | None = None
101
+ activity_bucket_count: int | None = None
98
102
 
99
103
 
100
104
  class TransportChannelMetrics:
101
105
  """Aggregates low-level channel events into user-visible metrics."""
102
106
 
103
- def __init__(self) -> None:
107
+ def __init__(
108
+ self,
109
+ bucket_seconds: int | None = None,
110
+ bucket_count: int | None = None,
111
+ ) -> None:
104
112
  self._lock = Lock()
105
113
 
106
114
  self._post_modes: set[str] = set()
@@ -155,8 +163,22 @@ class TransportChannelMetrics:
155
163
 
156
164
  self._response_channel_by_id: dict[RequestId, ChannelName] = {}
157
165
 
158
- self._history_bucket_seconds = 30
159
- self._history_bucket_count = 20
166
+ try:
167
+ seconds = 30 if bucket_seconds is None else int(bucket_seconds)
168
+ except (TypeError, ValueError):
169
+ seconds = 30
170
+ if seconds <= 0:
171
+ seconds = 30
172
+
173
+ try:
174
+ count = 20 if bucket_count is None else int(bucket_count)
175
+ except (TypeError, ValueError):
176
+ count = 20
177
+ if count <= 0:
178
+ count = 20
179
+
180
+ self._history_bucket_seconds = seconds
181
+ self._history_bucket_count = count
160
182
  self._history_priority = {
161
183
  "error": 5,
162
184
  "disabled": 4,
@@ -463,6 +485,8 @@ class TransportChannelMetrics:
463
485
  last_message_summary=stats.last_summary,
464
486
  last_message_at=stats.last_at,
465
487
  activity_buckets=self._build_activity_buckets(f"post-{mode}", now),
488
+ activity_bucket_seconds=self._history_bucket_seconds,
489
+ activity_bucket_count=self._history_bucket_count,
466
490
  )
467
491
 
468
492
  def snapshot(self) -> TransportSnapshot:
@@ -503,6 +527,8 @@ class TransportChannelMetrics:
503
527
  response_count=self._post_response_count,
504
528
  notification_count=self._post_notification_count,
505
529
  activity_buckets=self._merge_activity_buckets(["post-json", "post-sse"], now),
530
+ activity_bucket_seconds=self._history_bucket_seconds,
531
+ activity_bucket_count=self._history_bucket_count,
506
532
  )
507
533
 
508
534
  post_json_snapshot = self._build_post_mode_snapshot("json", now)
@@ -543,6 +569,8 @@ class TransportChannelMetrics:
543
569
  response_count=self._get_response_count,
544
570
  notification_count=self._get_notification_count,
545
571
  activity_buckets=self._build_activity_buckets("get", now),
572
+ activity_bucket_seconds=self._history_bucket_seconds,
573
+ activity_bucket_count=self._history_bucket_count,
546
574
  )
547
575
 
548
576
  resumption_snapshot = None
@@ -555,6 +583,8 @@ class TransportChannelMetrics:
555
583
  response_count=self._resumption_response_count,
556
584
  notification_count=self._resumption_notification_count,
557
585
  activity_buckets=self._build_activity_buckets("resumption", now),
586
+ activity_bucket_seconds=self._history_bucket_seconds,
587
+ activity_bucket_count=self._history_bucket_count,
558
588
  )
559
589
 
560
590
  stdio_snapshot = None
@@ -588,6 +618,8 @@ class TransportChannelMetrics:
588
618
  response_count=self._stdio_response_count,
589
619
  notification_count=self._stdio_notification_count,
590
620
  activity_buckets=self._build_activity_buckets("stdio", now),
621
+ activity_bucket_seconds=self._history_bucket_seconds,
622
+ activity_bucket_count=self._history_bucket_count,
591
623
  )
592
624
 
593
625
  return TransportSnapshot(
@@ -597,4 +629,6 @@ class TransportChannelMetrics:
597
629
  get=get_snapshot,
598
630
  resumption=resumption_snapshot,
599
631
  stdio=stdio_snapshot,
632
+ activity_bucket_seconds=self._history_bucket_seconds,
633
+ activity_bucket_count=self._history_bucket_count,
600
634
  )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ from fast_agent.interfaces import AgentProtocol
6
+
7
+ if TYPE_CHECKING:
8
+ from fast_agent.context import Context
9
+ from fast_agent.mcp.mcp_aggregator import MCPAggregator
10
+ from fast_agent.ui.console_display import ConsoleDisplay
11
+
12
+
13
+ @runtime_checkable
14
+ class McpAgentProtocol(AgentProtocol, Protocol):
15
+ """Agent protocol with MCP-specific surface area."""
16
+
17
+ @property
18
+ def aggregator(self) -> MCPAggregator: ...
19
+
20
+ @property
21
+ def display(self) -> "ConsoleDisplay": ...
22
+
23
+ @property
24
+ def context(self) -> "Context | None": ...
@@ -60,6 +60,7 @@ async def main() -> None:
60
60
  await agent.interactive(agent_name="route")
61
61
  for request in SAMPLE_REQUESTS:
62
62
  await agent.route(request)
63
+ await agent.interactive()
63
64
 
64
65
 
65
66
  if __name__ == "__main__":
@@ -15,6 +15,11 @@ default_model: gpt-5-mini.low
15
15
  # mcp_ui_output_dir: ".fast-agent/ui" # Where to write MCP-UI HTML files (relative to CWD if not absolute)
16
16
  # mcp_ui_mode: enabled
17
17
 
18
+ # MCP timeline display (adjust activity window/intervals in MCP UI + fast-agent check)
19
+ mcp_timeline:
20
+ steps: 20 # number of timeline buckets to render
21
+ step_seconds: 15 # seconds per bucket (accepts values like "45s", "2m")
22
+
18
23
  # Logging and Console Configuration:
19
24
  logger:
20
25
  # level: "debug" | "info" | "warning" | "error"
@@ -23,13 +28,14 @@ logger:
23
28
 
24
29
  # Switch the progress display on or off
25
30
  progress_display: true
26
-
27
31
  # Show chat User/Assistant messages on the console
28
32
  show_chat: true
29
33
  # Show tool calls on the console
30
34
  show_tools: true
31
35
  # Truncate long tool responses on the console
32
36
  truncate_tools: true
37
+ # Streaming renderer for assistant responses: "markdown", "plain", or "none"
38
+ streaming: markdown
33
39
 
34
40
  # MCP Servers
35
41
  mcp: