fast-agent-mcp 0.3.12__py3-none-any.whl → 0.3.14__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 (35) hide show
  1. fast_agent/agents/llm_agent.py +15 -34
  2. fast_agent/agents/llm_decorator.py +13 -2
  3. fast_agent/agents/mcp_agent.py +18 -2
  4. fast_agent/agents/tool_agent.py +8 -10
  5. fast_agent/cli/commands/check_config.py +45 -1
  6. fast_agent/config.py +63 -0
  7. fast_agent/constants.py +3 -0
  8. fast_agent/context.py +42 -9
  9. fast_agent/core/logging/listeners.py +1 -1
  10. fast_agent/event_progress.py +2 -3
  11. fast_agent/interfaces.py +9 -2
  12. fast_agent/llm/model_factory.py +4 -0
  13. fast_agent/llm/provider/google/google_converter.py +10 -3
  14. fast_agent/llm/provider_key_manager.py +1 -0
  15. fast_agent/llm/provider_types.py +1 -0
  16. fast_agent/llm/request_params.py +3 -1
  17. fast_agent/mcp/mcp_agent_client_session.py +13 -0
  18. fast_agent/mcp/mcp_aggregator.py +313 -40
  19. fast_agent/mcp/mcp_connection_manager.py +95 -22
  20. fast_agent/mcp/skybridge.py +45 -0
  21. fast_agent/mcp/sse_tracking.py +287 -0
  22. fast_agent/mcp/transport_tracking.py +37 -3
  23. fast_agent/mcp/types.py +24 -0
  24. fast_agent/resources/examples/workflows/router.py +1 -0
  25. fast_agent/resources/setup/fastagent.config.yaml +5 -0
  26. fast_agent/ui/console_display.py +347 -20
  27. fast_agent/ui/enhanced_prompt.py +107 -58
  28. fast_agent/ui/interactive_prompt.py +57 -34
  29. fast_agent/ui/mcp_display.py +159 -41
  30. fast_agent/ui/rich_progress.py +4 -1
  31. {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/METADATA +16 -7
  32. {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/RECORD +35 -32
  33. {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/WHEEL +0 -0
  34. {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/entry_points.txt +0 -0
  35. {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/licenses/LICENSE +0 -0
@@ -17,7 +17,6 @@ from anyio import Event, Lock, create_task_group
17
17
  from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
18
18
  from httpx import HTTPStatusError
19
19
  from mcp import ClientSession
20
- from mcp.client.sse import sse_client
21
20
  from mcp.client.stdio import (
22
21
  StdioServerParameters,
23
22
  get_default_environment,
@@ -33,11 +32,14 @@ from fast_agent.event_progress import ProgressAction
33
32
  from fast_agent.mcp.logger_textio import get_stderr_handler
34
33
  from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
35
34
  from fast_agent.mcp.oauth_client import build_oauth_provider
35
+ from fast_agent.mcp.sse_tracking import tracking_sse_client
36
36
  from fast_agent.mcp.stdio_tracking_simple import tracking_stdio_client
37
37
  from fast_agent.mcp.streamable_http_tracking import tracking_streamablehttp_client
38
38
  from fast_agent.mcp.transport_tracking import TransportChannelMetrics
39
39
 
40
40
  if TYPE_CHECKING:
41
+ from mcp.client.auth import OAuthClientProvider
42
+
41
43
  from fast_agent.context import Context
42
44
  from fast_agent.mcp_server_registry import ServerRegistry
43
45
 
@@ -65,6 +67,38 @@ def _add_none_to_context(context_manager):
65
67
  return StreamingContextAdapter(context_manager)
66
68
 
67
69
 
70
+ def _prepare_headers_and_auth(
71
+ server_config: MCPServerSettings,
72
+ ) -> tuple[dict[str, str], Optional["OAuthClientProvider"], set[str]]:
73
+ """
74
+ Prepare request headers and determine if OAuth authentication should be used.
75
+
76
+ Returns a copy of the headers, an OAuth auth provider when applicable, and the set
77
+ of user-supplied authorization header keys.
78
+ """
79
+ headers: dict[str, str] = dict(server_config.headers or {})
80
+ auth_header_keys = {"authorization", "x-hf-authorization"}
81
+ user_provided_auth_keys = {key for key in headers if key.lower() in auth_header_keys}
82
+
83
+ # OAuth is only relevant for SSE/HTTP transports and should be skipped when the
84
+ # user has already supplied explicit Authorization headers.
85
+ if server_config.transport not in ("sse", "http") or user_provided_auth_keys:
86
+ return headers, None, user_provided_auth_keys
87
+
88
+ oauth_auth = build_oauth_provider(server_config)
89
+ if oauth_auth is not None:
90
+ # Scrub Authorization headers so OAuth-managed credentials are the only ones sent.
91
+ for header_name in (
92
+ "Authorization",
93
+ "authorization",
94
+ "X-HF-Authorization",
95
+ "x-hf-authorization",
96
+ ):
97
+ headers.pop(header_name, None)
98
+
99
+ return headers, oauth_auth, user_provided_auth_keys
100
+
101
+
68
102
  class ServerConnection:
69
103
  """
70
104
  Represents a long-lived MCP server connection, including:
@@ -113,7 +147,9 @@ class ServerConnection:
113
147
  self.server_implementation: Implementation | None = None
114
148
  self.client_capabilities: dict | None = None
115
149
  self.server_instructions_available: bool = False
116
- self.server_instructions_enabled: bool = server_config.include_instructions if server_config else True
150
+ self.server_instructions_enabled: bool = (
151
+ server_config.include_instructions if server_config else True
152
+ )
117
153
  self.session_id: str | None = None
118
154
  self._get_session_id_cb: GetSessionIdCallback | None = None
119
155
  self.transport_metrics: TransportChannelMetrics | None = None
@@ -404,7 +440,27 @@ class MCPConnectionManager(ContextDependent):
404
440
 
405
441
  logger.debug(f"{server_name}: Found server configuration=", data=config.model_dump())
406
442
 
407
- transport_metrics = TransportChannelMetrics() if config.transport in ("http", "stdio") else None
443
+ timeline_steps = 20
444
+ timeline_seconds = 30
445
+ try:
446
+ ctx = self.context
447
+ except RuntimeError:
448
+ ctx = None
449
+
450
+ config_obj = getattr(ctx, "config", None)
451
+ timeline_config = getattr(config_obj, "mcp_timeline", None)
452
+ if timeline_config:
453
+ timeline_steps = getattr(timeline_config, "steps", timeline_steps)
454
+ timeline_seconds = getattr(timeline_config, "step_seconds", timeline_seconds)
455
+
456
+ transport_metrics = (
457
+ TransportChannelMetrics(
458
+ bucket_seconds=timeline_seconds,
459
+ bucket_count=timeline_steps,
460
+ )
461
+ if config.transport in ("http", "sse", "stdio")
462
+ else None
463
+ )
408
464
 
409
465
  def transport_context_factory():
410
466
  if config.transport == "stdio":
@@ -425,7 +481,9 @@ class MCPConnectionManager(ContextDependent):
425
481
 
426
482
  channel_hook = transport_metrics.record_event if transport_metrics else None
427
483
  return _add_none_to_context(
428
- tracking_stdio_client(server_params, channel_hook=channel_hook, errlog=error_handler)
484
+ tracking_stdio_client(
485
+ server_params, channel_hook=channel_hook, errlog=error_handler
486
+ )
429
487
  )
430
488
  elif config.transport == "sse":
431
489
  if not config.url:
@@ -434,38 +492,53 @@ class MCPConnectionManager(ContextDependent):
434
492
  )
435
493
  # Suppress MCP library error spam
436
494
  self._suppress_mcp_sse_errors()
437
- oauth_auth = build_oauth_provider(config)
438
- # If using OAuth, strip any pre-existing Authorization headers to avoid conflicts
439
- headers = dict(config.headers or {})
440
- if oauth_auth is not None:
441
- headers.pop("Authorization", None)
442
- headers.pop("X-HF-Authorization", None)
443
- return _add_none_to_context(
444
- sse_client(
445
- config.url,
446
- headers,
447
- sse_read_timeout=config.read_transport_sse_timeout_seconds,
448
- auth=oauth_auth,
495
+ headers, oauth_auth, user_auth_keys = _prepare_headers_and_auth(config)
496
+ if user_auth_keys:
497
+ logger.debug(
498
+ f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
499
+ user_auth_headers=sorted(user_auth_keys),
449
500
  )
501
+ channel_hook = None
502
+ if transport_metrics is not None:
503
+
504
+ def channel_hook(event):
505
+ try:
506
+ transport_metrics.record_event(event)
507
+ except Exception: # pragma: no cover - defensive guard
508
+ logger.debug(
509
+ "%s: transport metrics hook failed",
510
+ server_name,
511
+ exc_info=True,
512
+ )
513
+
514
+ return tracking_sse_client(
515
+ config.url,
516
+ headers,
517
+ sse_read_timeout=config.read_transport_sse_timeout_seconds,
518
+ auth=oauth_auth,
519
+ channel_hook=channel_hook,
450
520
  )
451
521
  elif config.transport == "http":
452
522
  if not config.url:
453
523
  raise ValueError(
454
524
  f"Server '{server_name}' uses http transport but no url is specified"
455
525
  )
456
- oauth_auth = build_oauth_provider(config)
457
- headers = dict(config.headers or {})
458
- if oauth_auth is not None:
459
- headers.pop("Authorization", None)
460
- headers.pop("X-HF-Authorization", None)
526
+ headers, oauth_auth, user_auth_keys = _prepare_headers_and_auth(config)
527
+ if user_auth_keys:
528
+ logger.debug(
529
+ f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
530
+ user_auth_headers=sorted(user_auth_keys),
531
+ )
461
532
  channel_hook = None
462
533
  if transport_metrics is not None:
534
+
463
535
  def channel_hook(event):
464
536
  try:
465
537
  transport_metrics.record_event(event)
466
538
  except Exception: # pragma: no cover - defensive guard
467
539
  logger.debug(
468
- "%s: transport metrics hook failed", server_name,
540
+ "%s: transport metrics hook failed",
541
+ server_name,
469
542
  exc_info=True,
470
543
  )
471
544
 
@@ -0,0 +1,45 @@
1
+ from typing import List
2
+
3
+ from pydantic import AnyUrl, BaseModel, Field
4
+
5
+ SKYBRIDGE_MIME_TYPE = "text/html+skybridge"
6
+
7
+
8
+ class SkybridgeResourceConfig(BaseModel):
9
+ """Represents a Skybridge (apps SDK) resource exposed by an MCP server."""
10
+
11
+ uri: AnyUrl
12
+ mime_type: str | None = None
13
+ is_skybridge: bool = False
14
+ warning: str | None = None
15
+
16
+
17
+ class SkybridgeToolConfig(BaseModel):
18
+ """Represents Skybridge metadata discovered for a tool."""
19
+
20
+ tool_name: str
21
+ namespaced_tool_name: str
22
+ template_uri: AnyUrl | None = None
23
+ resource_uri: AnyUrl | None = None
24
+ is_valid: bool = False
25
+ warning: str | None = None
26
+
27
+ @property
28
+ def display_name(self) -> str:
29
+ return self.namespaced_tool_name or self.tool_name
30
+
31
+
32
+ class SkybridgeServerConfig(BaseModel):
33
+ """Skybridge configuration discovered for a specific MCP server."""
34
+
35
+ server_name: str
36
+ supports_resources: bool = False
37
+ ui_resources: List[SkybridgeResourceConfig] = Field(default_factory=list)
38
+ warnings: List[str] = Field(default_factory=list)
39
+ tools: List[SkybridgeToolConfig] = Field(default_factory=list)
40
+
41
+ @property
42
+ def enabled(self) -> bool:
43
+ """Return True when at least one resource advertises the Skybridge MIME type."""
44
+ return any(resource.is_skybridge for resource in self.ui_resources)
45
+
@@ -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: 30 # 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"