Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/fast_agent/agents/mcp_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 25 additions & 6 deletions src/fast_agent/mcp/mcp_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}")

Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -995,14 +1008,20 @@ 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,
operation_type: str,
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:
"""
Expand Down
32 changes: 20 additions & 12 deletions src/fast_agent/ui/console_display.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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"]
Expand Down Expand Up @@ -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,
)

Expand Down
17 changes: 17 additions & 0 deletions src/fast_agent/ui/mcp_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down
22 changes: 22 additions & 0 deletions tests/integration/skybridge/fastagent.config.yaml
Original file line number Diff line number Diff line change
@@ -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"
123 changes: 123 additions & 0 deletions tests/integration/skybridge/skybridge_test_server.py
Original file line number Diff line number Diff line change
@@ -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 "<html><body><h1>Valid Skybridge Widget</h1></body></html>"

@server.resource(
"ui://skybridge/widget-valid",
description="Valid Skybridge UI resource",
mime_type=SKYBRIDGE_MIME_TYPE,
)
def valid_widget_resource() -> str:
return "<html><body><h1>Valid Skybridge Widget</h1></body></html>"

return server


def build_invalid_mime_scenario() -> SkybridgeTestServer:
server = SkybridgeTestServer(
name="Skybridge Invalid MIME Scenario",
tool_templates={"render_invalid_widget": "ui://skybridge/widget-invalid"},
)

@server.tool(
name="render_invalid_widget", description="Return HTML that lacks the Skybridge MIME type"
)
def render_invalid_widget() -> str:
return "<html><body><h1>Invalid MIME</h1></body></html>"

@server.resource(
"ui://skybridge/widget-invalid",
description="Resource served with a non-Skybridge mime type",
mime_type="text/html",
)
def invalid_widget_resource() -> str:
return "<html><body><h1>Invalid MIME</h1></body></html>"

return server


def build_missing_resource_scenario() -> SkybridgeTestServer:
server = SkybridgeTestServer(
name="Skybridge Missing Resource Scenario",
tool_templates={"render_missing_widget": "ui://skybridge/widget-missing"},
)

@server.tool(
name="render_missing_widget",
description="Advertises a template that does not exist on the server",
)
def render_missing_widget() -> str:
return "<html><body><h1>Missing Resource</h1></body></html>"

@server.resource(
"ui://skybridge/orphan-widget",
description="Orphaned Skybridge resource with no tool linkage",
mime_type=SKYBRIDGE_MIME_TYPE,
)
def orphan_widget_resource() -> str:
return "<html><body><h1>Orphan Widget</h1></body></html>"

return server


SCENARIO_BUILDERS = {
"valid": build_valid_scenario,
"invalid-mime": build_invalid_mime_scenario,
"missing-resource": build_missing_resource_scenario,
}


def main() -> None:
parser = argparse.ArgumentParser(description="Skybridge MCP test server scenarios")
parser.add_argument(
"scenario",
choices=SCENARIO_BUILDERS.keys(),
help="Which Skybridge scenario to run",
)
args = parser.parse_args()

server_factory = SCENARIO_BUILDERS[args.scenario]
app = server_factory()
app.run(transport="stdio")


if __name__ == "__main__":
main()
Loading
Loading