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.

@@ -1,4 +1,7 @@
1
1
  from asyncio import Lock, gather
2
+ from collections import Counter
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
2
5
  from typing import (
3
6
  TYPE_CHECKING,
4
7
  Any,
@@ -17,11 +20,12 @@ from mcp.types import (
17
20
  CallToolResult,
18
21
  ListToolsResult,
19
22
  Prompt,
23
+ ServerCapabilities,
20
24
  TextContent,
21
25
  Tool,
22
26
  )
23
27
  from opentelemetry import trace
24
- from pydantic import AnyUrl, BaseModel, ConfigDict
28
+ from pydantic import AnyUrl, BaseModel, ConfigDict, Field
25
29
 
26
30
  from fast_agent.context_dependent import ContextDependent
27
31
  from fast_agent.core.logging.logger import get_logger
@@ -30,6 +34,7 @@ from fast_agent.mcp.common import SEP, create_namespaced_name, is_namespaced_nam
30
34
  from fast_agent.mcp.gen_client import gen_client
31
35
  from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
32
36
  from fast_agent.mcp.mcp_connection_manager import MCPConnectionManager
37
+ from fast_agent.mcp.transport_tracking import TransportSnapshot
33
38
 
34
39
  if TYPE_CHECKING:
35
40
  from fast_agent.context import Context
@@ -52,6 +57,49 @@ class NamespacedTool(BaseModel):
52
57
  namespaced_tool_name: str
53
58
 
54
59
 
60
+ @dataclass
61
+ class ServerStats:
62
+ call_counts: Counter = field(default_factory=Counter)
63
+ last_call_at: datetime | None = None
64
+ last_error_at: datetime | None = None
65
+
66
+ def record(self, operation_type: str, success: bool) -> None:
67
+ self.call_counts[operation_type] += 1
68
+ now = datetime.now(timezone.utc)
69
+ self.last_call_at = now
70
+ if not success:
71
+ self.last_error_at = now
72
+
73
+
74
+ class ServerStatus(BaseModel):
75
+ server_name: str
76
+ implementation_name: str | None = None
77
+ implementation_version: str | None = None
78
+ server_capabilities: ServerCapabilities | None = None
79
+ client_capabilities: Mapping[str, Any] | None = None
80
+ client_info_name: str | None = None
81
+ client_info_version: str | None = None
82
+ transport: str | None = None
83
+ is_connected: bool | None = None
84
+ last_call_at: datetime | None = None
85
+ last_error_at: datetime | None = None
86
+ staleness_seconds: float | None = None
87
+ call_counts: Dict[str, int] = Field(default_factory=dict)
88
+ error_message: str | None = None
89
+ instructions_available: bool | None = None
90
+ instructions_enabled: bool | None = None
91
+ instructions_included: bool | None = None
92
+ roots_configured: bool | None = None
93
+ roots_count: int | None = None
94
+ elicitation_mode: str | None = None
95
+ sampling_mode: str | None = None
96
+ spoofing_enabled: bool | None = None
97
+ session_id: str | None = None
98
+ transport_channels: TransportSnapshot | None = None
99
+
100
+ model_config = ConfigDict(arbitrary_types_allowed=True)
101
+
102
+
55
103
  class MCPAggregator(ContextDependent):
56
104
  """
57
105
  Aggregates multiple MCP servers. When a developer calls, e.g. call_tool(...),
@@ -140,6 +188,10 @@ class MCPAggregator(ContextDependent):
140
188
  # Lock for refreshing tools from a server
141
189
  self._refresh_lock = Lock()
142
190
 
191
+ # Track runtime stats per server
192
+ self._server_stats: Dict[str, ServerStats] = {}
193
+ self._stats_lock = Lock()
194
+
143
195
  def _create_progress_callback(self, server_name: str, tool_name: str) -> "ProgressFnT":
144
196
  """Create a progress callback function for tool execution."""
145
197
 
@@ -282,6 +334,9 @@ class MCPAggregator(ContextDependent):
282
334
  server_name, client_session_factory=self._create_session_factory(server_name)
283
335
  )
284
336
 
337
+ # Record the initialize call that happened during connection setup
338
+ await self._record_server_call(server_name, "initialize", True)
339
+
285
340
  logger.info(
286
341
  f"MCP Servers initialized for agent '{self.agent_name}'",
287
342
  data={
@@ -290,27 +345,39 @@ class MCPAggregator(ContextDependent):
290
345
  },
291
346
  )
292
347
 
293
- async def fetch_tools(client: ClientSession, server_name: str) -> List[Tool]:
348
+ async def fetch_tools(server_name: str) -> List[Tool]:
294
349
  # Only fetch tools if the server supports them
295
350
  if not await self.server_supports_feature(server_name, "tools"):
296
351
  logger.debug(f"Server '{server_name}' does not support tools")
297
352
  return []
298
353
 
299
354
  try:
300
- result: ListToolsResult = await client.list_tools()
355
+ result: ListToolsResult = await self._execute_on_server(
356
+ server_name=server_name,
357
+ operation_type="tools/list",
358
+ operation_name="",
359
+ method_name="list_tools",
360
+ method_args={},
361
+ )
301
362
  return result.tools or []
302
363
  except Exception as e:
303
364
  logger.error(f"Error loading tools from server '{server_name}'", data=e)
304
365
  return []
305
366
 
306
- async def fetch_prompts(client: ClientSession, server_name: str) -> List[Prompt]:
367
+ async def fetch_prompts(server_name: str) -> List[Prompt]:
307
368
  # Only fetch prompts if the server supports them
308
369
  if not await self.server_supports_feature(server_name, "prompts"):
309
370
  logger.debug(f"Server '{server_name}' does not support prompts")
310
371
  return []
311
372
 
312
373
  try:
313
- result = await client.list_prompts()
374
+ result = await self._execute_on_server(
375
+ server_name=server_name,
376
+ operation_type="prompts/list",
377
+ operation_name="",
378
+ method_name="list_prompts",
379
+ method_args={},
380
+ )
314
381
  return getattr(result, "prompts", [])
315
382
  except Exception as e:
316
383
  logger.debug(f"Error loading prompts from server '{server_name}': {e}")
@@ -320,20 +387,9 @@ class MCPAggregator(ContextDependent):
320
387
  tools: List[Tool] = []
321
388
  prompts: List[Prompt] = []
322
389
 
323
- if self.connection_persistence:
324
- server_connection = await self._persistent_connection_manager.get_server(
325
- server_name, client_session_factory=self._create_session_factory(server_name)
326
- )
327
- tools = await fetch_tools(server_connection.session, server_name)
328
- prompts = await fetch_prompts(server_connection.session, server_name)
329
- else:
330
- async with gen_client(
331
- server_name,
332
- server_registry=self.context.server_registry,
333
- client_session_factory=self._create_session_factory(server_name),
334
- ) as client:
335
- tools = await fetch_tools(client, server_name)
336
- prompts = await fetch_prompts(client, server_name)
390
+ # Use _execute_on_server for consistent tracking regardless of connection mode
391
+ tools = await fetch_tools(server_name)
392
+ prompts = await fetch_prompts(server_name)
337
393
 
338
394
  return server_name, tools, prompts
339
395
 
@@ -461,6 +517,50 @@ class MCPAggregator(ContextDependent):
461
517
  for server_name in self.server_names:
462
518
  await self._refresh_server_tools(server_name)
463
519
 
520
+ async def _record_server_call(
521
+ self, server_name: str, operation_type: str, success: bool
522
+ ) -> None:
523
+ async with self._stats_lock:
524
+ stats = self._server_stats.setdefault(server_name, ServerStats())
525
+ stats.record(operation_type, success)
526
+
527
+ # For stdio servers, also emit synthetic transport events to create activity timeline
528
+ await self._notify_stdio_transport_activity(server_name, operation_type, success)
529
+
530
+ async def _notify_stdio_transport_activity(
531
+ self, server_name: str, operation_type: str, success: bool
532
+ ) -> None:
533
+ """Notify transport metrics of activity for stdio servers to create activity timeline."""
534
+ if not self._persistent_connection_manager:
535
+ return
536
+
537
+ try:
538
+ # Get the server connection and check if it's stdio transport
539
+ server_conn = self._persistent_connection_manager.running_servers.get(server_name)
540
+ if not server_conn:
541
+ return
542
+
543
+ server_config = getattr(server_conn, "server_config", None)
544
+ if not server_config or server_config.transport != "stdio":
545
+ return
546
+
547
+ # Get transport metrics and emit synthetic message event
548
+ transport_metrics = getattr(server_conn, "transport_metrics", None)
549
+ if transport_metrics:
550
+ # Import here to avoid circular imports
551
+ from fast_agent.mcp.transport_tracking import ChannelEvent
552
+
553
+ # Create a synthetic message event to represent the MCP operation
554
+ event = ChannelEvent(
555
+ channel="stdio",
556
+ event_type="message",
557
+ detail=f"{operation_type} ({'success' if success else 'error'})"
558
+ )
559
+ transport_metrics.record_event(event)
560
+ except Exception:
561
+ # Don't let transport tracking errors break normal operation
562
+ logger.debug("Failed to notify stdio transport activity for %s", server_name, exc_info=True)
563
+
464
564
  async def get_server_instructions(self) -> Dict[str, tuple[str, List[str]]]:
465
565
  """
466
566
  Get instructions from all connected servers along with their tool names.
@@ -492,6 +592,174 @@ class MCPAggregator(ContextDependent):
492
592
 
493
593
  return instructions
494
594
 
595
+ async def collect_server_status(self) -> Dict[str, ServerStatus]:
596
+ """Return aggregated status information for each configured server."""
597
+ if not self.initialized:
598
+ await self.load_servers()
599
+
600
+ now = datetime.now(timezone.utc)
601
+ status_map: Dict[str, ServerStatus] = {}
602
+
603
+ for server_name in self.server_names:
604
+ stats = self._server_stats.get(server_name)
605
+ last_call = stats.last_call_at if stats else None
606
+ last_error = stats.last_error_at if stats else None
607
+ staleness = (now - last_call).total_seconds() if last_call else None
608
+ call_counts = dict(stats.call_counts) if stats else {}
609
+
610
+ implementation_name = None
611
+ implementation_version = None
612
+ capabilities: ServerCapabilities | None = None
613
+ client_capabilities: Mapping[str, Any] | None = None
614
+ client_info_name = None
615
+ client_info_version = None
616
+ is_connected = None
617
+ error_message = None
618
+ instructions_available = None
619
+ instructions_enabled = None
620
+ instructions_included = None
621
+ roots_configured = None
622
+ roots_count = None
623
+ elicitation_mode = None
624
+ sampling_mode = None
625
+ spoofing_enabled = None
626
+ server_cfg = None
627
+ session_id = None
628
+ server_conn = None
629
+ transport: str | None = None
630
+ transport_snapshot: TransportSnapshot | None = None
631
+
632
+ manager = getattr(self, "_persistent_connection_manager", None)
633
+ if self.connection_persistence and manager is not None:
634
+ try:
635
+ server_conn = await manager.get_server(
636
+ server_name,
637
+ client_session_factory=self._create_session_factory(server_name),
638
+ )
639
+ implementation = getattr(server_conn, "server_implementation", None)
640
+ if implementation:
641
+ implementation_name = getattr(implementation, "name", None)
642
+ implementation_version = getattr(implementation, "version", None)
643
+ capabilities = getattr(server_conn, "server_capabilities", None)
644
+ client_capabilities = getattr(server_conn, "client_capabilities", None)
645
+ session = server_conn.session
646
+ client_info = getattr(session, "client_info", None) if session else None
647
+ if client_info:
648
+ client_info_name = getattr(client_info, "name", None)
649
+ client_info_version = getattr(client_info, "version", None)
650
+ is_connected = server_conn.is_healthy()
651
+ error_message = getattr(server_conn, "_error_message", None)
652
+ instructions_available = getattr(
653
+ server_conn, "server_instructions_available", None
654
+ )
655
+ instructions_enabled = getattr(
656
+ server_conn, "server_instructions_enabled", None
657
+ )
658
+ instructions_included = bool(getattr(server_conn, "server_instructions", None))
659
+ server_cfg = getattr(server_conn, "server_config", None)
660
+ if session:
661
+ elicitation_mode = getattr(session, "effective_elicitation_mode", elicitation_mode)
662
+ session_id = getattr(server_conn, "session_id", None)
663
+ if not session_id and getattr(server_conn, "_get_session_id_cb", None):
664
+ try:
665
+ session_id = server_conn._get_session_id_cb() # type: ignore[attr-defined]
666
+ except Exception:
667
+ session_id = None
668
+ metrics = getattr(server_conn, "transport_metrics", None)
669
+ if metrics is not None:
670
+ try:
671
+ transport_snapshot = metrics.snapshot()
672
+ except Exception:
673
+ logger.debug(
674
+ "Failed to snapshot transport metrics for server '%s'",
675
+ server_name,
676
+ exc_info=True,
677
+ )
678
+ except Exception as exc:
679
+ logger.debug(
680
+ f"Failed to collect status for server '{server_name}'",
681
+ data={"error": str(exc)},
682
+ )
683
+
684
+ if server_cfg is None and self.context and getattr(self.context, "server_registry", None):
685
+ try:
686
+ server_cfg = self.context.server_registry.get_server_config(server_name)
687
+ except Exception:
688
+ server_cfg = None
689
+
690
+ if server_cfg is not None:
691
+ instructions_enabled = (
692
+ instructions_enabled
693
+ if instructions_enabled is not None
694
+ else server_cfg.include_instructions
695
+ )
696
+ roots = getattr(server_cfg, "roots", None)
697
+ roots_configured = bool(roots)
698
+ roots_count = len(roots) if roots else 0
699
+ transport = getattr(server_cfg, "transport", transport)
700
+ elicitation = getattr(server_cfg, "elicitation", None)
701
+ elicitation_mode = (
702
+ getattr(elicitation, "mode", None)
703
+ if elicitation
704
+ else elicitation_mode
705
+ )
706
+ sampling_cfg = getattr(server_cfg, "sampling", None)
707
+ spoofing_enabled = bool(getattr(server_cfg, "implementation", None))
708
+ if implementation_name is None and getattr(server_cfg, "implementation", None):
709
+ implementation_name = server_cfg.implementation.name
710
+ implementation_version = getattr(server_cfg.implementation, "version", None)
711
+ if session_id is None:
712
+ if server_cfg.transport == "stdio":
713
+ session_id = "local"
714
+ elif server_conn and getattr(server_conn, "_get_session_id_cb", None):
715
+ try:
716
+ session_id = server_conn._get_session_id_cb() # type: ignore[attr-defined]
717
+ except Exception:
718
+ session_id = None
719
+
720
+ if sampling_cfg is not None:
721
+ sampling_mode = "configured"
722
+ else:
723
+ auto_sampling = True
724
+ if self.context and getattr(self.context, "config", None):
725
+ auto_sampling = getattr(self.context.config, "auto_sampling", True)
726
+ sampling_mode = "auto" if auto_sampling else "off"
727
+ else:
728
+ # Fall back to defaults when config missing
729
+ auto_sampling = True
730
+ if self.context and getattr(self.context, "config", None):
731
+ auto_sampling = getattr(self.context.config, "auto_sampling", True)
732
+ sampling_mode = sampling_mode or ("auto" if auto_sampling else "off")
733
+
734
+ status_map[server_name] = ServerStatus(
735
+ server_name=server_name,
736
+ implementation_name=implementation_name,
737
+ implementation_version=implementation_version,
738
+ server_capabilities=capabilities,
739
+ client_capabilities=client_capabilities,
740
+ client_info_name=client_info_name,
741
+ client_info_version=client_info_version,
742
+ transport=transport,
743
+ is_connected=is_connected,
744
+ last_call_at=last_call,
745
+ last_error_at=last_error,
746
+ staleness_seconds=staleness,
747
+ call_counts=call_counts,
748
+ error_message=error_message,
749
+ instructions_available=instructions_available,
750
+ instructions_enabled=instructions_enabled,
751
+ instructions_included=instructions_included,
752
+ roots_configured=roots_configured,
753
+ roots_count=roots_count,
754
+ elicitation_mode=elicitation_mode,
755
+ sampling_mode=sampling_mode,
756
+ spoofing_enabled=spoofing_enabled,
757
+ session_id=session_id,
758
+ transport_channels=transport_snapshot,
759
+ )
760
+
761
+ return status_map
762
+
495
763
  async def _execute_on_server(
496
764
  self,
497
765
  server_name: str,
@@ -554,13 +822,17 @@ class MCPAggregator(ContextDependent):
554
822
  # Re-raise the original exception to propagate it
555
823
  raise e
556
824
 
825
+ success_flag: bool | None = None
826
+ result: R | None = None
827
+
557
828
  # Try initial execution
558
829
  try:
559
830
  if self.connection_persistence:
560
831
  server_connection = await self._persistent_connection_manager.get_server(
561
832
  server_name, client_session_factory=self._create_session_factory(server_name)
562
833
  )
563
- return await try_execute(server_connection.session)
834
+ result = await try_execute(server_connection.session)
835
+ success_flag = True
564
836
  else:
565
837
  logger.debug(
566
838
  f"Creating temporary connection to server: {server_name}",
@@ -582,7 +854,7 @@ class MCPAggregator(ContextDependent):
582
854
  "agent_name": self.agent_name,
583
855
  },
584
856
  )
585
- return result
857
+ success_flag = True
586
858
  except ConnectionError:
587
859
  # Server offline - attempt reconnection
588
860
  from fast_agent.ui import console
@@ -613,7 +885,7 @@ class MCPAggregator(ContextDependent):
613
885
 
614
886
  # Success!
615
887
  console.console.print(f"[dim green]MCP server {server_name} online[/dim green]")
616
- return result
888
+ success_flag = True
617
889
 
618
890
  except Exception:
619
891
  # Reconnection failed
@@ -621,10 +893,19 @@ class MCPAggregator(ContextDependent):
621
893
  f"[dim red]MCP server {server_name} offline - failed to reconnect[/dim red]"
622
894
  )
623
895
  error_msg = f"MCP server {server_name} offline - failed to reconnect"
896
+ success_flag = False
624
897
  if error_factory:
625
- return error_factory(error_msg)
898
+ result = error_factory(error_msg)
626
899
  else:
627
900
  raise Exception(error_msg)
901
+ except Exception:
902
+ success_flag = False
903
+ raise
904
+ finally:
905
+ if success_flag is not None:
906
+ await self._record_server_call(server_name, operation_type, success_flag)
907
+
908
+ return result
628
909
 
629
910
  async def _parse_resource_name(self, name: str, resource_type: str) -> tuple[str, str]:
630
911
  """
@@ -701,7 +982,7 @@ class MCPAggregator(ContextDependent):
701
982
 
702
983
  return await self._execute_on_server(
703
984
  server_name=server_name,
704
- operation_type="tool",
985
+ operation_type="tools/call",
705
986
  operation_name=local_tool_name,
706
987
  method_name="call_tool",
707
988
  method_args={
@@ -794,7 +1075,7 @@ class MCPAggregator(ContextDependent):
794
1075
 
795
1076
  result = await self._execute_on_server(
796
1077
  server_name=server_name,
797
- operation_type="prompt",
1078
+ operation_type="prompts/get",
798
1079
  operation_name=local_prompt_name or "default",
799
1080
  method_name="get_prompt",
800
1081
  method_args=method_args,
@@ -842,7 +1123,7 @@ class MCPAggregator(ContextDependent):
842
1123
 
843
1124
  result = await self._execute_on_server(
844
1125
  server_name=s_name,
845
- operation_type="prompt",
1126
+ operation_type="prompts/get",
846
1127
  operation_name=local_prompt_name,
847
1128
  method_name="get_prompt",
848
1129
  method_args=method_args,
@@ -890,7 +1171,7 @@ class MCPAggregator(ContextDependent):
890
1171
 
891
1172
  result = await self._execute_on_server(
892
1173
  server_name=s_name,
893
- operation_type="prompt",
1174
+ operation_type="prompts/get",
894
1175
  operation_name=local_prompt_name,
895
1176
  method_name="get_prompt",
896
1177
  method_args=method_args,
@@ -913,7 +1194,7 @@ class MCPAggregator(ContextDependent):
913
1194
  try:
914
1195
  prompt_list_result = await self._execute_on_server(
915
1196
  server_name=s_name,
916
- operation_type="prompts-list",
1197
+ operation_type="prompts/list",
917
1198
  operation_name="",
918
1199
  method_name="list_prompts",
919
1200
  error_factory=lambda _: None,
@@ -987,7 +1268,7 @@ class MCPAggregator(ContextDependent):
987
1268
  # Fetch from server
988
1269
  result = await self._execute_on_server(
989
1270
  server_name=server_name,
990
- operation_type="prompts-list",
1271
+ operation_type="prompts/list",
991
1272
  operation_name="",
992
1273
  method_name="list_prompts",
993
1274
  error_factory=lambda _: None,
@@ -1026,7 +1307,7 @@ class MCPAggregator(ContextDependent):
1026
1307
  try:
1027
1308
  result = await self._execute_on_server(
1028
1309
  server_name=s_name,
1029
- operation_type="prompts-list",
1310
+ operation_type="prompts/list",
1030
1311
  operation_name="",
1031
1312
  method_name="list_prompts",
1032
1313
  error_factory=lambda _: None,
@@ -1212,7 +1493,7 @@ class MCPAggregator(ContextDependent):
1212
1493
  # Use the _execute_on_server method to call read_resource on the server
1213
1494
  result = await self._execute_on_server(
1214
1495
  server_name=server_name,
1215
- operation_type="resource",
1496
+ operation_type="resources/read",
1216
1497
  operation_name=resource_uri,
1217
1498
  method_name="read_resource",
1218
1499
  method_args={"uri": uri},
@@ -1263,7 +1544,7 @@ class MCPAggregator(ContextDependent):
1263
1544
  # Use the _execute_on_server method to call list_resources on the server
1264
1545
  result = await self._execute_on_server(
1265
1546
  server_name=s_name,
1266
- operation_type="resources-list",
1547
+ operation_type="resources/list",
1267
1548
  operation_name="",
1268
1549
  method_name="list_resources",
1269
1550
  method_args={}, # Empty dictionary instead of None
@@ -1316,7 +1597,7 @@ class MCPAggregator(ContextDependent):
1316
1597
  # Use the _execute_on_server method to call list_tools on the server
1317
1598
  result = await self._execute_on_server(
1318
1599
  server_name=s_name,
1319
- operation_type="tools-list",
1600
+ operation_type="tools/list",
1320
1601
  operation_name="",
1321
1602
  method_name="list_tools",
1322
1603
  method_args={},