fast-agent-mcp 0.3.13__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 (33) hide show
  1. fast_agent/agents/llm_agent.py +14 -33
  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_key_manager.py +1 -0
  14. fast_agent/llm/provider_types.py +1 -0
  15. fast_agent/llm/request_params.py +3 -1
  16. fast_agent/mcp/mcp_aggregator.py +313 -40
  17. fast_agent/mcp/mcp_connection_manager.py +39 -9
  18. fast_agent/mcp/skybridge.py +45 -0
  19. fast_agent/mcp/sse_tracking.py +287 -0
  20. fast_agent/mcp/transport_tracking.py +37 -3
  21. fast_agent/mcp/types.py +24 -0
  22. fast_agent/resources/examples/workflows/router.py +1 -0
  23. fast_agent/resources/setup/fastagent.config.yaml +5 -0
  24. fast_agent/ui/console_display.py +295 -18
  25. fast_agent/ui/enhanced_prompt.py +107 -58
  26. fast_agent/ui/interactive_prompt.py +57 -34
  27. fast_agent/ui/mcp_display.py +108 -27
  28. fast_agent/ui/rich_progress.py +4 -1
  29. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.14.dist-info}/METADATA +2 -2
  30. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.14.dist-info}/RECORD +33 -30
  31. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.14.dist-info}/WHEEL +0 -0
  32. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.14.dist-info}/entry_points.txt +0 -0
  33. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.14.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,7 @@ from typing import (
11
11
  Mapping,
12
12
  Optional,
13
13
  TypeVar,
14
+ cast,
14
15
  )
15
16
 
16
17
  from mcp import GetPromptResult, ReadResourceResult
@@ -34,6 +35,12 @@ from fast_agent.mcp.common import SEP, create_namespaced_name, is_namespaced_nam
34
35
  from fast_agent.mcp.gen_client import gen_client
35
36
  from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
36
37
  from fast_agent.mcp.mcp_connection_manager import MCPConnectionManager
38
+ from fast_agent.mcp.skybridge import (
39
+ SKYBRIDGE_MIME_TYPE,
40
+ SkybridgeResourceConfig,
41
+ SkybridgeServerConfig,
42
+ SkybridgeToolConfig,
43
+ )
37
44
  from fast_agent.mcp.transport_tracking import TransportSnapshot
38
45
 
39
46
  if TYPE_CHECKING:
@@ -96,6 +103,7 @@ class ServerStatus(BaseModel):
96
103
  spoofing_enabled: bool | None = None
97
104
  session_id: str | None = None
98
105
  transport_channels: TransportSnapshot | None = None
106
+ skybridge: SkybridgeServerConfig | None = None
99
107
 
100
108
  model_config = ConfigDict(arbitrary_types_allowed=True)
101
109
 
@@ -124,12 +132,17 @@ class MCPAggregator(ContextDependent):
124
132
  # Keep a connection manager to manage persistent connections for this aggregator
125
133
  if self.connection_persistence:
126
134
  # Try to get existing connection manager from context
127
- if not hasattr(self.context, "_connection_manager"):
128
- self.context._connection_manager = MCPConnectionManager(
129
- self.context.server_registry
130
- )
131
- await self.context._connection_manager.__aenter__()
132
- self._persistent_connection_manager = self.context._connection_manager
135
+ context = self.context
136
+ if not hasattr(context, "_connection_manager") or context._connection_manager is None:
137
+ server_registry = context.server_registry
138
+ if server_registry is None:
139
+ raise RuntimeError("Context is missing server registry for MCP connections")
140
+ manager = MCPConnectionManager(server_registry, context=context)
141
+ await manager.__aenter__()
142
+ context._connection_manager = manager
143
+ self._persistent_connection_manager = cast(
144
+ "MCPConnectionManager", context._connection_manager
145
+ )
133
146
 
134
147
  # Import the display component here to avoid circular imports
135
148
  from fast_agent.ui.console_display import ConsoleDisplay
@@ -149,7 +162,7 @@ class MCPAggregator(ContextDependent):
149
162
  server_names: List[str],
150
163
  connection_persistence: bool = True,
151
164
  context: Optional["Context"] = None,
152
- name: str = None,
165
+ name: str | None = None,
153
166
  config: Optional[Any] = None, # Accept the agent config for elicitation_handler access
154
167
  **kwargs,
155
168
  ) -> None:
@@ -168,7 +181,6 @@ class MCPAggregator(ContextDependent):
168
181
  self.connection_persistence = connection_persistence
169
182
  self.agent_name = name
170
183
  self.config = config # Store the config for access in session factory
171
- self._persistent_connection_manager: MCPConnectionManager = None
172
184
 
173
185
  # Set up logger with agent name in namespace if available
174
186
  global logger
@@ -192,6 +204,9 @@ class MCPAggregator(ContextDependent):
192
204
  self._server_stats: Dict[str, ServerStats] = {}
193
205
  self._stats_lock = Lock()
194
206
 
207
+ # Track discovered Skybridge configurations per server
208
+ self._skybridge_configs: Dict[str, SkybridgeServerConfig] = {}
209
+
195
210
  def _create_progress_callback(self, server_name: str, tool_name: str) -> "ProgressFnT":
196
211
  """Create a progress callback function for tool execution."""
197
212
 
@@ -319,6 +334,8 @@ class MCPAggregator(ContextDependent):
319
334
  async with self._prompt_cache_lock:
320
335
  self._prompt_cache.clear()
321
336
 
337
+ self._skybridge_configs.clear()
338
+
322
339
  for server_name in self.server_names:
323
340
  if self.connection_persistence:
324
341
  logger.info(
@@ -399,6 +416,9 @@ class MCPAggregator(ContextDependent):
399
416
  return_exceptions=True,
400
417
  )
401
418
 
419
+ total_tool_count = 0
420
+ total_prompt_count = 0
421
+
402
422
  for result in results:
403
423
  if isinstance(result, BaseException):
404
424
  logger.error(f"Error loading server data: {result}")
@@ -419,10 +439,14 @@ class MCPAggregator(ContextDependent):
419
439
  self._namespaced_tool_map[namespaced_tool_name] = namespaced_tool
420
440
  self._server_to_tool_map[server_name].append(namespaced_tool)
421
441
 
442
+ total_tool_count += len(tools)
443
+
422
444
  # Process prompts
423
445
  async with self._prompt_cache_lock:
424
446
  self._prompt_cache[server_name] = prompts
425
447
 
448
+ total_prompt_count += len(prompts)
449
+
426
450
  logger.debug(
427
451
  f"MCP Aggregator initialized for server '{server_name}'",
428
452
  data={
@@ -434,8 +458,195 @@ class MCPAggregator(ContextDependent):
434
458
  },
435
459
  )
436
460
 
461
+ await self._initialize_skybridge_configs()
462
+
463
+ self._display_startup_state(total_tool_count, total_prompt_count)
464
+
437
465
  self.initialized = True
438
466
 
467
+ async def _initialize_skybridge_configs(self) -> None:
468
+ """Discover Skybridge resources across servers."""
469
+ if not self.server_names:
470
+ return
471
+
472
+ tasks = [
473
+ self._evaluate_skybridge_for_server(server_name) for server_name in self.server_names
474
+ ]
475
+ results = await gather(*tasks, return_exceptions=True)
476
+
477
+ for result in results:
478
+ if isinstance(result, BaseException):
479
+ logger.debug("Skybridge discovery failed: %s", str(result))
480
+ continue
481
+
482
+ server_name, config = result
483
+ self._skybridge_configs[server_name] = config
484
+
485
+ async def _evaluate_skybridge_for_server(
486
+ self, server_name: str
487
+ ) -> tuple[str, SkybridgeServerConfig]:
488
+ """Inspect a single server for Skybridge-compatible resources."""
489
+ config = SkybridgeServerConfig(server_name=server_name)
490
+
491
+ tool_entries = self._server_to_tool_map.get(server_name, [])
492
+ tool_configs: List[SkybridgeToolConfig] = []
493
+
494
+ for namespaced_tool in tool_entries:
495
+ tool_meta = getattr(namespaced_tool.tool, "meta", None) or {}
496
+ template_value = tool_meta.get("openai/outputTemplate")
497
+ if not template_value:
498
+ continue
499
+
500
+ try:
501
+ template_uri = AnyUrl(template_value)
502
+ except Exception as exc:
503
+ warning = (
504
+ f"Tool '{namespaced_tool.namespaced_tool_name}' outputTemplate "
505
+ f"'{template_value}' is invalid: {exc}"
506
+ )
507
+ config.warnings.append(warning)
508
+ logger.error(warning)
509
+ tool_configs.append(
510
+ SkybridgeToolConfig(
511
+ tool_name=namespaced_tool.tool.name,
512
+ namespaced_tool_name=namespaced_tool.namespaced_tool_name,
513
+ warning=warning,
514
+ )
515
+ )
516
+ continue
517
+
518
+ tool_configs.append(
519
+ SkybridgeToolConfig(
520
+ tool_name=namespaced_tool.tool.name,
521
+ namespaced_tool_name=namespaced_tool.namespaced_tool_name,
522
+ template_uri=template_uri,
523
+ )
524
+ )
525
+
526
+ raw_resources_capability = await self.server_supports_feature(server_name, "resources")
527
+ supports_resources = bool(raw_resources_capability)
528
+ config.supports_resources = supports_resources
529
+ config.tools = tool_configs
530
+
531
+ if not supports_resources:
532
+ return server_name, config
533
+
534
+ try:
535
+ resources = await self._list_resources_from_server(server_name, check_support=False)
536
+ except Exception as exc: # noqa: BLE001 - logging and surfacing gracefully
537
+ config.warnings.append(f"Failed to list resources: {exc}")
538
+ return server_name, config
539
+
540
+ for resource_entry in resources:
541
+ uri = getattr(resource_entry, "uri", None)
542
+ if not uri:
543
+ continue
544
+
545
+ uri_str = str(uri)
546
+ if not uri_str.startswith("ui://"):
547
+ continue
548
+
549
+ try:
550
+ uri_value = AnyUrl(uri_str)
551
+ except Exception as exc: # noqa: BLE001
552
+ warning = f"Ignoring Skybridge candidate '{uri_str}': invalid URI ({exc})"
553
+ config.warnings.append(warning)
554
+ logger.debug(warning)
555
+ continue
556
+
557
+ sky_resource = SkybridgeResourceConfig(uri=uri_value)
558
+ config.ui_resources.append(sky_resource)
559
+
560
+ try:
561
+ read_result: ReadResourceResult = await self._get_resource_from_server(
562
+ server_name, uri_str
563
+ )
564
+ except Exception as exc: # noqa: BLE001
565
+ warning = f"Failed to read resource '{uri_str}': {exc}"
566
+ sky_resource.warning = warning
567
+ config.warnings.append(warning)
568
+ continue
569
+
570
+ contents = getattr(read_result, "contents", []) or []
571
+ seen_mime_types: List[str] = []
572
+
573
+ for content in contents:
574
+ mime_type = getattr(content, "mimeType", None)
575
+ if mime_type:
576
+ seen_mime_types.append(mime_type)
577
+ if mime_type == SKYBRIDGE_MIME_TYPE:
578
+ sky_resource.mime_type = mime_type
579
+ sky_resource.is_skybridge = True
580
+ break
581
+
582
+ if sky_resource.mime_type is None and seen_mime_types:
583
+ sky_resource.mime_type = seen_mime_types[0]
584
+
585
+ if not sky_resource.is_skybridge:
586
+ observed_type = sky_resource.mime_type or "unknown MIME type"
587
+ warning = (
588
+ f"served as '{observed_type}' instead of '{SKYBRIDGE_MIME_TYPE}'"
589
+ )
590
+ sky_resource.warning = warning
591
+ config.warnings.append(f"{uri_str}: {warning}")
592
+
593
+ resource_lookup = {str(resource.uri): resource for resource in config.ui_resources}
594
+ for tool_config in tool_configs:
595
+ if tool_config.template_uri is None:
596
+ continue
597
+
598
+ resource_match = resource_lookup.get(str(tool_config.template_uri))
599
+ if not resource_match:
600
+ warning = (
601
+ f"Tool '{tool_config.namespaced_tool_name}' references missing "
602
+ f"Skybridge resource '{tool_config.template_uri}'"
603
+ )
604
+ tool_config.warning = warning
605
+ config.warnings.append(warning)
606
+ logger.error(warning)
607
+ continue
608
+
609
+ tool_config.resource_uri = resource_match.uri
610
+ tool_config.is_valid = resource_match.is_skybridge
611
+
612
+ if not resource_match.is_skybridge:
613
+ warning = (
614
+ f"Tool '{tool_config.namespaced_tool_name}' references resource "
615
+ f"'{resource_match.uri}' served as '{resource_match.mime_type or 'unknown'}' "
616
+ f"instead of '{SKYBRIDGE_MIME_TYPE}'"
617
+ )
618
+ tool_config.warning = warning
619
+ config.warnings.append(warning)
620
+ logger.warning(warning)
621
+
622
+ config.tools = tool_configs
623
+
624
+ valid_tool_count = sum(1 for tool in tool_configs if tool.is_valid)
625
+ if config.enabled and valid_tool_count == 0:
626
+ warning = (
627
+ f"Skybridge resources detected on server '{server_name}' but no tools expose them"
628
+ )
629
+ config.warnings.append(warning)
630
+ logger.warning(warning)
631
+
632
+ return server_name, config
633
+
634
+ def _display_startup_state(self, total_tool_count: int, total_prompt_count: int) -> None:
635
+ """Display startup summary and Skybridge status information."""
636
+ # In interactive contexts the UI helper will render both the agent summary and the
637
+ # Skybridge status. For non-interactive contexts, the warnings collected during
638
+ # discovery are emitted through the logger, so we don't need to duplicate output here.
639
+ if not self._skybridge_configs:
640
+ return
641
+
642
+ logger.debug(
643
+ "Skybridge discovery completed",
644
+ data={
645
+ "agent_name": self.agent_name,
646
+ "server_count": len(self._skybridge_configs),
647
+ },
648
+ )
649
+
439
650
  async def get_capabilities(self, server_name: str):
440
651
  """Get server capabilities if available."""
441
652
  if not self.connection_persistence:
@@ -485,7 +696,15 @@ class MCPAggregator(ContextDependent):
485
696
  if not capabilities:
486
697
  return False
487
698
 
488
- return getattr(capabilities, feature, False)
699
+ feature_value = getattr(capabilities, feature, False)
700
+ if isinstance(feature_value, bool):
701
+ return feature_value
702
+ if feature_value is None:
703
+ return False
704
+ try:
705
+ return bool(feature_value)
706
+ except Exception: # noqa: BLE001
707
+ return True
489
708
 
490
709
  async def list_servers(self) -> List[str]:
491
710
  """Return the list of server names aggregated by this agent."""
@@ -501,12 +720,30 @@ class MCPAggregator(ContextDependent):
501
720
  if not self.initialized:
502
721
  await self.load_servers()
503
722
 
504
- return ListToolsResult(
505
- tools=[
506
- namespaced_tool.tool.model_copy(update={"name": namespaced_tool_name})
507
- for namespaced_tool_name, namespaced_tool in self._namespaced_tool_map.items()
508
- ]
509
- )
723
+ tools: List[Tool] = []
724
+
725
+ for namespaced_tool_name, namespaced_tool in self._namespaced_tool_map.items():
726
+ tool_copy = namespaced_tool.tool.model_copy(
727
+ deep=True, update={"name": namespaced_tool_name}
728
+ )
729
+ skybridge_config = self._skybridge_configs.get(namespaced_tool.server_name)
730
+ if skybridge_config:
731
+ matching_tool = next(
732
+ (
733
+ tool
734
+ for tool in skybridge_config.tools
735
+ if tool.namespaced_tool_name == namespaced_tool_name and tool.is_valid
736
+ ),
737
+ None,
738
+ )
739
+ if matching_tool:
740
+ meta = dict(tool_copy.meta or {})
741
+ meta["openai/skybridgeEnabled"] = True
742
+ meta["openai/skybridgeTemplate"] = str(matching_tool.template_uri)
743
+ tool_copy.meta = meta
744
+ tools.append(tool_copy)
745
+
746
+ return ListToolsResult(tools=tools)
510
747
 
511
748
  async def refresh_all_tools(self) -> None:
512
749
  """
@@ -554,12 +791,14 @@ class MCPAggregator(ContextDependent):
554
791
  event = ChannelEvent(
555
792
  channel="stdio",
556
793
  event_type="message",
557
- detail=f"{operation_type} ({'success' if success else 'error'})"
794
+ detail=f"{operation_type} ({'success' if success else 'error'})",
558
795
  )
559
796
  transport_metrics.record_event(event)
560
797
  except Exception:
561
798
  # 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)
799
+ logger.debug(
800
+ "Failed to notify stdio transport activity for %s", server_name, exc_info=True
801
+ )
563
802
 
564
803
  async def get_server_instructions(self) -> Dict[str, tuple[str, List[str]]]:
565
804
  """
@@ -652,13 +891,13 @@ class MCPAggregator(ContextDependent):
652
891
  instructions_available = getattr(
653
892
  server_conn, "server_instructions_available", None
654
893
  )
655
- instructions_enabled = getattr(
656
- server_conn, "server_instructions_enabled", None
657
- )
894
+ instructions_enabled = getattr(server_conn, "server_instructions_enabled", None)
658
895
  instructions_included = bool(getattr(server_conn, "server_instructions", None))
659
896
  server_cfg = getattr(server_conn, "server_config", None)
660
897
  if session:
661
- elicitation_mode = getattr(session, "effective_elicitation_mode", elicitation_mode)
898
+ elicitation_mode = getattr(
899
+ session, "effective_elicitation_mode", elicitation_mode
900
+ )
662
901
  session_id = getattr(server_conn, "session_id", None)
663
902
  if not session_id and getattr(server_conn, "_get_session_id_cb", None):
664
903
  try:
@@ -681,7 +920,11 @@ class MCPAggregator(ContextDependent):
681
920
  data={"error": str(exc)},
682
921
  )
683
922
 
684
- if server_cfg is None and self.context and getattr(self.context, "server_registry", None):
923
+ if (
924
+ server_cfg is None
925
+ and self.context
926
+ and getattr(self.context, "server_registry", None)
927
+ ):
685
928
  try:
686
929
  server_cfg = self.context.server_registry.get_server_config(server_name)
687
930
  except Exception:
@@ -699,9 +942,7 @@ class MCPAggregator(ContextDependent):
699
942
  transport = getattr(server_cfg, "transport", transport)
700
943
  elicitation = getattr(server_cfg, "elicitation", None)
701
944
  elicitation_mode = (
702
- getattr(elicitation, "mode", None)
703
- if elicitation
704
- else elicitation_mode
945
+ getattr(elicitation, "mode", None) if elicitation else elicitation_mode
705
946
  )
706
947
  sampling_cfg = getattr(server_cfg, "sampling", None)
707
948
  spoofing_enabled = bool(getattr(server_cfg, "implementation", None))
@@ -756,10 +997,23 @@ class MCPAggregator(ContextDependent):
756
997
  spoofing_enabled=spoofing_enabled,
757
998
  session_id=session_id,
758
999
  transport_channels=transport_snapshot,
1000
+ skybridge=self._skybridge_configs.get(server_name),
759
1001
  )
760
1002
 
761
1003
  return status_map
762
1004
 
1005
+ async def get_skybridge_configs(self) -> Dict[str, SkybridgeServerConfig]:
1006
+ """Expose discovered Skybridge configurations keyed by server."""
1007
+ if not self.initialized:
1008
+ await self.load_servers()
1009
+ return dict(self._skybridge_configs)
1010
+
1011
+ async def get_skybridge_config(self, server_name: str) -> SkybridgeServerConfig | None:
1012
+ """Return the Skybridge configuration for a specific server, loading if necessary."""
1013
+ if not self.initialized:
1014
+ await self.load_servers()
1015
+ return self._skybridge_configs.get(server_name)
1016
+
763
1017
  async def _execute_on_server(
764
1018
  self,
765
1019
  server_name: str,
@@ -767,7 +1021,7 @@ class MCPAggregator(ContextDependent):
767
1021
  operation_name: str,
768
1022
  method_name: str,
769
1023
  method_args: Dict[str, Any] = None,
770
- error_factory: Callable[[str], R] = None,
1024
+ error_factory: Callable[[str], R] | None = None,
771
1025
  progress_callback: ProgressFnT | None = None,
772
1026
  ) -> R:
773
1027
  """
@@ -1500,6 +1754,32 @@ class MCPAggregator(ContextDependent):
1500
1754
 
1501
1755
  return result
1502
1756
 
1757
+ async def _list_resources_from_server(
1758
+ self, server_name: str, *, check_support: bool = True
1759
+ ) -> List[Any]:
1760
+ """
1761
+ Internal helper method to list resources from a specific server.
1762
+
1763
+ Args:
1764
+ server_name: Name of the server whose resources to list
1765
+ check_support: Whether to verify the server supports resources before listing
1766
+
1767
+ Returns:
1768
+ A list of resources as returned by the MCP server
1769
+ """
1770
+ if check_support and not await self.server_supports_feature(server_name, "resources"):
1771
+ return []
1772
+
1773
+ result = await self._execute_on_server(
1774
+ server_name=server_name,
1775
+ operation_type="resources/list",
1776
+ operation_name="",
1777
+ method_name="list_resources",
1778
+ method_args={},
1779
+ )
1780
+
1781
+ return getattr(result, "resources", []) or []
1782
+
1503
1783
  async def list_resources(self, server_name: str | None = None) -> Dict[str, List[str]]:
1504
1784
  """
1505
1785
  List available resources from one or all servers.
@@ -1534,20 +1814,13 @@ class MCPAggregator(ContextDependent):
1534
1814
  continue
1535
1815
 
1536
1816
  try:
1537
- # Use the _execute_on_server method to call list_resources on the server
1538
- result = await self._execute_on_server(
1539
- server_name=s_name,
1540
- operation_type="resources/list",
1541
- operation_name="",
1542
- method_name="list_resources",
1543
- method_args={}, # Empty dictionary instead of None
1544
- # No error_factory to allow exceptions to propagate
1545
- )
1546
-
1547
- # Get resources from result
1548
- resources = getattr(result, "resources", [])
1549
- results[s_name] = [str(r.uri) for r in resources]
1550
-
1817
+ resources = await self._list_resources_from_server(s_name, check_support=False)
1818
+ formatted_resources: List[str] = []
1819
+ for resource in resources:
1820
+ uri = getattr(resource, "uri", None)
1821
+ if uri is not None:
1822
+ formatted_resources.append(str(uri))
1823
+ results[s_name] = formatted_resources
1551
1824
  except Exception as e:
1552
1825
  logger.error(f"Error fetching resources from {s_name}: {e}")
1553
1826
 
@@ -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,6 +32,7 @@ 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
@@ -440,8 +440,26 @@ class MCPConnectionManager(ContextDependent):
440
440
 
441
441
  logger.debug(f"{server_name}: Found server configuration=", data=config.model_dump())
442
442
 
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
+
443
456
  transport_metrics = (
444
- TransportChannelMetrics() if config.transport in ("http", "stdio") else None
457
+ TransportChannelMetrics(
458
+ bucket_seconds=timeline_seconds,
459
+ bucket_count=timeline_steps,
460
+ )
461
+ if config.transport in ("http", "sse", "stdio")
462
+ else None
445
463
  )
446
464
 
447
465
  def transport_context_factory():
@@ -480,13 +498,25 @@ class MCPConnectionManager(ContextDependent):
480
498
  f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
481
499
  user_auth_headers=sorted(user_auth_keys),
482
500
  )
483
- return _add_none_to_context(
484
- sse_client(
485
- config.url,
486
- headers,
487
- sse_read_timeout=config.read_transport_sse_timeout_seconds,
488
- auth=oauth_auth,
489
- )
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,
490
520
  )
491
521
  elif config.transport == "http":
492
522
  if not config.url:
@@ -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
+