diff --git a/src/fast_agent/agents/mcp_agent.py b/src/fast_agent/agents/mcp_agent.py index 0fd86091..246304b2 100644 --- a/src/fast_agent/agents/mcp_agent.py +++ b/src/fast_agent/agents/mcp_agent.py @@ -668,13 +668,13 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend result = await self.call_tool(tool_name, tool_args) tool_results[correlation_id] = result - # Get skybridge config for this tool if available + # Show tool result (like ToolAgent does) skybridge_config = None if namespaced_tool: - server_name = namespaced_tool.server_name - skybridge_config = self._aggregator._skybridge_configs.get(server_name) + skybridge_config = await self._aggregator.get_skybridge_config( + namespaced_tool.server_name + ) - # Show tool result (like ToolAgent does) self.display.show_tool_result( name=self._name, result=result, diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 751acdb0..19511cf9 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -162,7 +162,7 @@ def __init__( server_names: List[str], connection_persistence: bool = True, context: Optional["Context"] = None, - name: str = None, + name: str | None = None, config: Optional[Any] = None, # Accept the agent config for elicitation_handler access **kwargs, ) -> None: @@ -523,7 +523,8 @@ async def _evaluate_skybridge_for_server( ) ) - supports_resources = await self.server_supports_feature(server_name, "resources") + raw_resources_capability = await self.server_supports_feature(server_name, "resources") + supports_resources = bool(raw_resources_capability) config.supports_resources = supports_resources config.tools = tool_configs @@ -582,7 +583,10 @@ async def _evaluate_skybridge_for_server( sky_resource.mime_type = seen_mime_types[0] if not sky_resource.is_skybridge: - warning = "ui:// detected but resource is not of type 'text/html+skybridge'" + observed_type = sky_resource.mime_type or "unknown MIME type" + warning = ( + f"served as '{observed_type}' instead of '{SKYBRIDGE_MIME_TYPE}'" + ) sky_resource.warning = warning config.warnings.append(f"{uri_str}: {warning}") @@ -608,7 +612,8 @@ async def _evaluate_skybridge_for_server( if not resource_match.is_skybridge: warning = ( f"Tool '{tool_config.namespaced_tool_name}' references resource " - f"'{resource_match.uri}' that is not Skybridge MIME type" + f"'{resource_match.uri}' served as '{resource_match.mime_type or 'unknown'}' " + f"instead of '{SKYBRIDGE_MIME_TYPE}'" ) tool_config.warning = warning config.warnings.append(warning) @@ -691,7 +696,15 @@ async def server_supports_feature(self, server_name: str, feature: str) -> bool: if not capabilities: return False - return getattr(capabilities, feature, False) + feature_value = getattr(capabilities, feature, False) + if isinstance(feature_value, bool): + return feature_value + if feature_value is None: + return False + try: + return bool(feature_value) + except Exception: # noqa: BLE001 + return True async def list_servers(self) -> List[str]: """Return the list of server names aggregated by this agent.""" @@ -995,6 +1008,12 @@ async def get_skybridge_configs(self) -> Dict[str, SkybridgeServerConfig]: await self.load_servers() return dict(self._skybridge_configs) + async def get_skybridge_config(self, server_name: str) -> SkybridgeServerConfig | None: + """Return the Skybridge configuration for a specific server, loading if necessary.""" + if not self.initialized: + await self.load_servers() + return self._skybridge_configs.get(server_name) + async def _execute_on_server( self, server_name: str, @@ -1002,7 +1021,7 @@ async def _execute_on_server( operation_name: str, method_name: str, method_args: Dict[str, Any] = None, - error_factory: Callable[[str], R] = None, + error_factory: Callable[[str], R] | None = None, progress_callback: ProgressFnT | None = None, ) -> R: """ diff --git a/src/fast_agent/ui/console_display.py b/src/fast_agent/ui/console_display.py index 0865bd56..eee7932f 100644 --- a/src/fast_agent/ui/console_display.py +++ b/src/fast_agent/ui/console_display.py @@ -1,6 +1,6 @@ from enum import Enum from json import JSONDecodeError -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Union +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union from mcp.types import CallToolResult from rich.panel import Panel @@ -870,19 +870,18 @@ def _create_combined_separator_status(self, left_content: str, right_info: str = console.console.print(combined, markup=self._markup) console.console.print() - def show_skybridge_summary( - self, - agent_name: str, + @staticmethod + def summarize_skybridge_configs( configs: Mapping[str, "SkybridgeServerConfig"] | None, - ) -> None: - """Display Skybridge availability and warnings.""" - if configs is None: - return - + ) -> Tuple[List[Dict[str, Any]], List[str]]: + """Convert raw Skybridge configs into display-friendly summary data.""" server_rows: List[Dict[str, Any]] = [] warnings: List[str] = [] warning_seen: Set[str] = set() + if not configs: + return server_rows, warnings + def add_warning(message: str) -> None: formatted = message.strip() if not formatted: @@ -931,6 +930,16 @@ def add_warning(message: str) -> None: message = f"{server_name} {message}" add_warning(message) + return server_rows, warnings + + def show_skybridge_summary( + self, + agent_name: str, + configs: Mapping[str, "SkybridgeServerConfig"] | None, + ) -> None: + """Display Skybridge availability and warnings.""" + server_rows, warnings = self.summarize_skybridge_configs(configs) + if not server_rows and not warnings: return @@ -943,7 +952,6 @@ def add_warning(message: str) -> None: else: for row in server_rows: server_name = row["server_name"] - config = row["config"] resource_count = row["valid_resource_count"] total_resource_count = row["total_resource_count"] tool_infos = row["active_tools"] @@ -985,14 +993,14 @@ def add_warning(message: str) -> None: console.console.print( ( "[dim] ▶ " - f"{invalid_count} {invalid_word} detected with non-skybridge MIME type[/dim]" + f"[/dim][cyan]{invalid_count}[/cyan][dim] {invalid_word} detected with non-skybridge MIME type[/dim]" ), markup=self._markup, ) for warning_entry in warnings: console.console.print( - f"[yellow]skybridge warning[/yellow] {warning_entry}", + f"[dim red] ▶ [/dim red][red]warning[/red] [dim]{warning_entry}[/dim]", markup=self._markup, ) diff --git a/src/fast_agent/ui/mcp_display.py b/src/fast_agent/ui/mcp_display.py index 129a137c..56391606 100644 --- a/src/fast_agent/ui/mcp_display.py +++ b/src/fast_agent/ui/mcp_display.py @@ -39,6 +39,7 @@ class Colours: # Capability token states TOKEN_ERROR = "bright_red" TOKEN_WARNING = "bright_cyan" + TOKEN_CAUTION = "bright_yellow" TOKEN_DISABLED = "dim" TOKEN_HIGHLIGHTED = "bright_yellow" TOKEN_ENABLED = "bright_green" @@ -232,6 +233,18 @@ def _format_capability_shorthand( else: entries.append(("In", "blue", False)) + skybridge_config = getattr(status, "skybridge", None) + if not skybridge_config: + entries.append(("Sk", False, False)) + else: + has_warnings = bool(getattr(skybridge_config, "warnings", None)) + if has_warnings: + entries.append(("Sk", "warn", False)) + elif getattr(skybridge_config, "enabled", False): + entries.append(("Sk", True, False)) + else: + entries.append(("Sk", False, False)) + if status.roots_configured: entries.append(("Ro", True, False)) else: @@ -260,6 +273,8 @@ def token_style(supported, highlighted) -> str: return Colours.TOKEN_ERROR if supported == "blue": return Colours.TOKEN_WARNING + if supported == "warn": + return Colours.TOKEN_CAUTION if not supported: return Colours.TOKEN_DISABLED if highlighted: @@ -652,6 +667,8 @@ def _render_channel_summary(status: ServerStatus, indent: str, total_width: int) symbol = SYMBOL_ERROR elif name == "ping": symbol = SYMBOL_PING + elif is_stdio and name == "activity": + symbol = SYMBOL_STDIO_ACTIVITY else: symbol = SYMBOL_RESPONSE footer.append(symbol, style=f"{color}") diff --git a/tests/integration/skybridge/fastagent.config.yaml b/tests/integration/skybridge/fastagent.config.yaml new file mode 100644 index 00000000..19572a79 --- /dev/null +++ b/tests/integration/skybridge/fastagent.config.yaml @@ -0,0 +1,22 @@ +default_model: passthrough + +logger: + level: "info" + progress_display: false + show_chat: false + show_tools: false + +mcp: + servers: + skybridge_valid: + command: "uv" + args: ["run", "skybridge_test_server.py", "valid"] + description: "Skybridge server with valid resource/tool pairing" + skybridge_invalid_mime: + command: "uv" + args: ["run", "skybridge_test_server.py", "invalid-mime"] + description: "Skybridge server exposing ui:// resource without Skybridge MIME type" + skybridge_missing_resource: + command: "uv" + args: ["run", "skybridge_test_server.py", "missing-resource"] + description: "Skybridge server with Skybridge resources but missing tool linkage" diff --git a/tests/integration/skybridge/skybridge_test_server.py b/tests/integration/skybridge/skybridge_test_server.py new file mode 100644 index 00000000..e9aea21a --- /dev/null +++ b/tests/integration/skybridge/skybridge_test_server.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Skybridge-focused MCP test server exposing multiple scenarios.""" + +from __future__ import annotations + +import argparse +from typing import TYPE_CHECKING + +from mcp.server.fastmcp import FastMCP + +if TYPE_CHECKING: + from mcp.types import Tool as MCPTool + +SKYBRIDGE_MIME_TYPE = "text/html+skybridge" + + +class SkybridgeTestServer(FastMCP): + """FastMCP server that decorates tool listings with Skybridge meta tags.""" + + def __init__(self, *args, tool_templates: dict[str, str] | None = None, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._tool_templates = tool_templates or {} + + async def list_tools(self) -> list[MCPTool]: + tools = await super().list_tools() + for tool in tools: + template = self._tool_templates.get(tool.name) + if template: + tool.meta = {"openai/outputTemplate": template} + return tools + + +def build_valid_scenario() -> SkybridgeTestServer: + server = SkybridgeTestServer( + name="Skybridge Valid Scenario", + tool_templates={"render_valid_widget": "ui://skybridge/widget-valid"}, + ) + + @server.tool(name="render_valid_widget", description="Return HTML for a valid Skybridge widget") + def render_valid_widget() -> str: + return "