diff --git a/examples/a2a/agent_executor.py b/examples/a2a/agent_executor.py index c8e57c677..f0722a65a 100644 --- a/examples/a2a/agent_executor.py +++ b/examples/a2a/agent_executor.py @@ -18,7 +18,7 @@ DEFAULT_AGENT_NAME = "helper" fast = FastAgent( - "A2A FastAgent Demo", + "A2A fast-agent Demo", parse_cli_args=False, quiet=True, ) diff --git a/src/fast_agent/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index 4ff2c06c6..4902c0a67 100644 --- a/src/fast_agent/agents/agent_types.py +++ b/src/fast_agent/agents/agent_types.py @@ -5,7 +5,6 @@ from dataclasses import dataclass, field from enum import StrEnum, auto from pathlib import Path -from typing import Dict, List, Optional from mcp.client.session import ElicitationFnT @@ -35,12 +34,12 @@ class AgentConfig: name: str instruction: str = "You are a helpful agent." - servers: List[str] = field(default_factory=list) - tools: Optional[Dict[str, List[str]]] = None - resources: Optional[Dict[str, List[str]]] = None - prompts: Optional[Dict[str, List[str]]] = None + servers: list[str] = field(default_factory=list) + tools: dict[str, list[str]] = field(default_factory=dict) # filters for tools + resources: dict[str, list[str]] = field(default_factory=dict) # filters for resources + prompts: dict[str, list[str]] = field(default_factory=dict) # filters for prompts skills: SkillManifest | SkillRegistry | Path | str | None = None - skill_manifests: List[SkillManifest] = field(default_factory=list, repr=False) + skill_manifests: list[SkillManifest] = field(default_factory=list, repr=False) model: str | None = None use_history: bool = True default_request_params: RequestParams | None = None diff --git a/src/fast_agent/agents/llm_agent.py b/src/fast_agent/agents/llm_agent.py index acfd46e7f..f62d98497 100644 --- a/src/fast_agent/agents/llm_agent.py +++ b/src/fast_agent/agents/llm_agent.py @@ -10,18 +10,7 @@ from typing import Callable, List, Optional, Tuple -try: - from a2a.types import AgentCapabilities # type: ignore -except Exception: # pragma: no cover - optional dependency fallback - from dataclasses import dataclass - - @dataclass - class AgentCapabilities: # minimal fallback - streaming: bool = False - push_notifications: bool = False - state_transition_history: bool = False - - +from a2a.types import AgentCapabilities from mcp import Tool from rich.text import Text diff --git a/src/fast_agent/agents/mcp_agent.py b/src/fast_agent/agents/mcp_agent.py index 1a6bea2e4..ceb9e3166 100644 --- a/src/fast_agent/agents/mcp_agent.py +++ b/src/fast_agent/agents/mcp_agent.py @@ -11,6 +11,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Dict, List, Mapping, @@ -41,7 +42,7 @@ from fast_agent.core.exceptions import PromptExitError from fast_agent.core.logging.logger import get_logger from fast_agent.interfaces import FastAgentLLMProtocol -from fast_agent.mcp.common import SEP +from fast_agent.mcp.common import get_resource_name, get_server_name, is_namespaced_name from fast_agent.mcp.mcp_aggregator import MCPAggregator, ServerStatus from fast_agent.skills.registry import format_skills_for_prompt from fast_agent.tools.elicitation import ( @@ -55,6 +56,7 @@ # Define a TypeVar for models ModelT = TypeVar("ModelT", bound=BaseModel) +ItemT = TypeVar("ItemT") LLM = TypeVar("LLM", bound=FastAgentLLMProtocol) @@ -345,104 +347,107 @@ async def __call__( ) -> str: return await self.send(message) - # async def send( - # self, - # message: Union[ - # str, - # PromptMessage, - # PromptMessageExtended, - # Sequence[Union[str, PromptMessage, PromptMessageExtended]], - # ], - # request_params: RequestParams | None = None, - # ) -> str: - # """ - # Send a message to the agent and get a response. - - # Args: - # message: Message content in various formats: - # - String: Converted to a user PromptMessageExtended - # - PromptMessage: Converted to PromptMessageExtended - # - PromptMessageExtended: Used directly - # - request_params: Optional request parameters - - # Returns: - # The agent's response as a string - # """ - # response = await self.generate(message, request_params) - # return response.last_text() or "" - - def _matches_pattern(self, name: str, pattern: str, server_name: str) -> bool: + def _matches_pattern(self, name: str, pattern: str) -> bool: """ Check if a name matches a pattern for a specific server. Args: name: The name to match (could be tool name, resource URI, or prompt name) pattern: The pattern to match against (e.g., "add", "math*", "resource://math/*") - server_name: The server name (used for tool name prefixing) Returns: True if the name matches the pattern """ - # For tools, build the full pattern with server prefix: server_name-pattern - if name.startswith(f"{server_name}-"): - full_pattern = f"{server_name}-{pattern}" - return fnmatch.fnmatch(name, full_pattern) # For resources and prompts, match directly against the pattern return fnmatch.fnmatch(name, pattern) - async def list_tools(self) -> ListToolsResult: + def _filter_namespaced_tools(self, tools: Sequence[Tool] | None) -> list[Tool]: """ - List all tools available to this agent, filtered by configuration. + Apply configuration-based filtering to a collection of tools. + """ + if not tools: + return [] - Returns: - ListToolsResult with available tools + return [ + tool + for tool in tools + if is_namespaced_name(tool.name) and self._tool_matches_filter(tool.name) + ] + + def _filter_server_collections( + self, + items_by_server: Mapping[str, Sequence[ItemT]], + filters: Mapping[str, Sequence[str]] | None, + value_getter: Callable[[ItemT], str], + ) -> dict[str, list[ItemT]]: """ - aggregator_result = await self._aggregator.list_tools() - aggregator_tools = list(aggregator_result.tools or []) - - # Apply filtering if tools are specified in config - if self.config.tools is not None: - filtered_tools: list[Tool] = [] - for tool in aggregator_tools: - # Extract server name from tool name, handling server names with hyphens - server_name = None - for configured_server in self.config.tools.keys(): - if tool.name.startswith(f"{configured_server}{SEP}"): - server_name = configured_server - break - - if not server_name: - continue - - # Check if tool matches any pattern for this server - for pattern in self.config.tools[server_name]: - if self._matches_pattern(tool.name, pattern, server_name): - filtered_tools.append(tool) - break - aggregator_tools = filtered_tools + Apply server-specific filters to a mapping of collections. + """ + if not items_by_server: + return {} - # Start with filtered aggregator tools and merge in subclass/local tools - merged_tools: list[Tool] = list(aggregator_tools) - existing_names = {tool.name for tool in merged_tools} + if not filters: + return {server: list(items) for server, items in items_by_server.items()} - local_tools = (await ToolAgent.list_tools(self)).tools - for tool in local_tools: - if tool.name not in existing_names: - merged_tools.append(tool) - existing_names.add(tool.name) + filtered: dict[str, list[ItemT]] = {} + for server, items in items_by_server.items(): + patterns = filters.get(server) + if patterns is None: + filtered[server] = list(items) + continue - if self._bash_tool and self._bash_tool.name not in existing_names: - merged_tools.append(self._bash_tool) - existing_names.add(self._bash_tool.name) + matches = [ + item + for item in items + if any(self._matches_pattern(value_getter(item), pattern) for pattern in patterns) + ] + if matches: + filtered[server] = matches - if self.config.human_input: - human_tool = getattr(self, "_human_input_tool", None) - if human_tool and human_tool.name not in existing_names: - merged_tools.append(human_tool) - existing_names.add(human_tool.name) + return filtered - return ListToolsResult(tools=merged_tools) + def _filter_server_tools(self, tools: list[Tool] | None, namespace: str) -> list[Tool]: + """ + Filter items for a Server (not namespaced) + """ + if not tools: + return [] + + filters = self.config.tools + if not filters: + return list(tools) + + if namespace not in filters: + return list(tools) + + filtered = self._filter_server_collections({namespace: tools}, filters, lambda tool: tool.name) + return filtered.get(namespace, []) + + async def _get_filtered_mcp_tools(self) -> list[Tool]: + """ + Get the list of tools available to this agent, applying configured filters. + + Returns: + List of Tool objects + """ + aggregator_result = await self._aggregator.list_tools() + return self._filter_namespaced_tools(aggregator_result.tools) + + def _tool_matches_filter(self, packed_name: str) -> bool: + """ + Check if a tool name matches the agent's tool configuration. + + Args: + tool_name: The name of the tool to check (namespaced) + """ + server_name = get_server_name(packed_name) + config_tools = self.config.tools or {} + if server_name not in config_tools: + return True + resource_name = get_resource_name(packed_name) + patterns = config_tools.get(server_name, []) + return any(self._matches_pattern(resource_name, pattern) for pattern in patterns) async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> CallToolResult: """ @@ -860,37 +865,6 @@ async def apply_prompt_template(self, prompt_result: GetPromptResult, prompt_nam with self._tracer.start_as_current_span(f"Agent: '{self._name}' apply_prompt_template"): return await self._llm.apply_prompt_template(prompt_result, prompt_name) - # async def structured( - # self, - # messages: Union[ - # str, - # PromptMessage, - # PromptMessageExtended, - # Sequence[Union[str, PromptMessage, PromptMessageExtended]], - # ], - # model: Type[ModelT], - # request_params: RequestParams | None = None, - # ) -> Tuple[ModelT | None, PromptMessageExtended]: - # """ - # Apply the prompt and return the result as a Pydantic model. - # Normalizes input messages and delegates to the attached LLM. - - # Args: - # messages: Message(s) in various formats: - # - String: Converted to a user PromptMessageExtended - # - PromptMessage: Converted to PromptMessageExtended - # - PromptMessageExtended: Used directly - # - List of any combination of the above - # model: The Pydantic model class to parse the result into - # request_params: Optional parameters to configure the LLM request - - # Returns: - # An instance of the specified model, or None if coercion fails - # """ - - # with self._tracer.start_as_current_span(f"Agent: '{self._name}' structured"): - # return await super().structured(messages, model, request_params) - async def apply_prompt_messages( self, prompts: List[PromptMessageExtended], request_params: RequestParams | None = None ) -> str: @@ -924,24 +898,11 @@ async def list_prompts( target = namespace if namespace is not None else server_name result = await self._aggregator.list_prompts(target) - # Apply filtering if prompts are specified in config - if self.config.prompts is not None: - filtered_result = {} - for server, prompts in result.items(): - # Check if this server has prompt filters - if server in self.config.prompts: - filtered_prompts = [] - for prompt in prompts: - # Check if prompt matches any pattern for this server - for pattern in self.config.prompts[server]: - if self._matches_pattern(prompt.name, pattern, server): - filtered_prompts.append(prompt) - break - if filtered_prompts: - filtered_result[server] = filtered_prompts - result = filtered_result - - return result + return self._filter_server_collections( + result, + self.config.prompts, + lambda prompt: prompt.name, + ) async def list_resources( self, namespace: str | None = None, server_name: str | None = None @@ -959,28 +920,13 @@ async def list_resources( target = namespace if namespace is not None else server_name result = await self._aggregator.list_resources(target) - # Apply filtering if resources are specified in config - if self.config.resources is not None: - filtered_result = {} - for server, resources in result.items(): - # Check if this server has resource filters - if server in self.config.resources: - filtered_resources = [] - for resource in resources: - # Check if resource matches any pattern for this server - for pattern in self.config.resources[server]: - if self._matches_pattern(resource, pattern, server): - filtered_resources.append(resource) - break - if filtered_resources: - filtered_result[server] = filtered_resources - result = filtered_result - - return result + return self._filter_server_collections( + result, + self.config.resources, + lambda resource: resource, + ) - async def list_mcp_tools( - self, namespace: str | None = None, server_name: str | None = None - ) -> Mapping[str, List[Tool]]: + async def list_mcp_tools(self, namespace: str | None = None) -> Mapping[str, List[Tool]]: """ List all tools available to this agent, grouped by server and filtered by configuration. @@ -991,40 +937,47 @@ async def list_mcp_tools( Dictionary mapping server names to lists of Tool objects (with original names, not namespaced) """ # Get all tools from the aggregator - target = namespace if namespace is not None else server_name - result = await self._aggregator.list_mcp_tools(target) - - # Apply filtering if tools are specified in config - if self.config.tools is not None: - filtered_result = {} - for server, tools in result.items(): - # Check if this server has tool filters - if server in self.config.tools: - filtered_tools = [] - for tool in tools: - # Check if tool matches any pattern for this server - for pattern in self.config.tools[server]: - if self._matches_pattern(tool.name, pattern, server): - filtered_tools.append(tool) - break - if filtered_tools: - filtered_result[server] = filtered_tools - result = filtered_result + result = await self._aggregator.list_mcp_tools(namespace) + filtered_result: dict[str, list[Tool]] = {} + + for server, server_tools in result.items(): + filtered_result[server] = self._filter_server_tools(server_tools, server) # Add elicitation-backed human input tool to a special server if enabled and available - if self.config.human_input and getattr(self, "_human_input_tool", None): + if self.config.human_input and self._human_input_tool: special_server_name = "__human_input__" + filtered_result.setdefault(special_server_name, []).append(self._human_input_tool) + + return filtered_result + + async def list_tools(self) -> ListToolsResult: + """ + List all tools available to this agent, filtered by configuration. + + Returns: + ListToolsResult with available tools + """ + # Start with filtered aggregator tools and merge in subclass/local tools + merged_tools: list[Tool] = await self._get_filtered_mcp_tools() + existing_names = {tool.name for tool in merged_tools} - # If the special server doesn't exist in result, create it - if special_server_name not in result: - result[special_server_name] = [] + local_tools = (await super().list_tools()).tools + for tool in local_tools: + if tool.name not in existing_names: + merged_tools.append(tool) + existing_names.add(tool.name) - result[special_server_name].append(self._human_input_tool) + if self._bash_tool and self._bash_tool.name not in existing_names: + merged_tools.append(self._bash_tool) + existing_names.add(self._bash_tool.name) - # if self._skill_lookup_tool: - # result.setdefault("__skills__", []).append(self._skill_lookup_tool) + if self.config.human_input: + human_tool = getattr(self, "_human_input_tool", None) + if human_tool and human_tool.name not in existing_names: + merged_tools.append(human_tool) + existing_names.add(human_tool.name) - return result + return ListToolsResult(tools=merged_tools) @property def agent_type(self) -> AgentType: diff --git a/src/fast_agent/mcp/__init__.py b/src/fast_agent/mcp/__init__.py index 8243e52ee..825337ab2 100644 --- a/src/fast_agent/mcp/__init__.py +++ b/src/fast_agent/mcp/__init__.py @@ -10,6 +10,7 @@ via `fast_agent.mcp.prompt_message_multipart`, which subclasses `PromptMessageExtended`. """ +from .common import SEP from .helpers import ( ensure_multipart_messages, get_image_data, @@ -27,6 +28,8 @@ __all__ = [ "Prompt", + # Common + "SEP", # Helpers "get_text", "get_image_data", diff --git a/src/fast_agent/mcp/common.py b/src/fast_agent/mcp/common.py index 5935dc463..a1ed5d88f 100644 --- a/src/fast_agent/mcp/common.py +++ b/src/fast_agent/mcp/common.py @@ -3,7 +3,7 @@ """ # Constants -SEP = "-" +SEP = "__" def create_namespaced_name(server_name: str, resource_name: str) -> str: @@ -14,3 +14,13 @@ def create_namespaced_name(server_name: str, resource_name: str) -> str: def is_namespaced_name(name: str) -> bool: """Check if a name is already namespaced""" return SEP in name + + +def get_server_name(namespaced_name: str) -> str: + """Extract the server name from a namespaced resource name""" + return namespaced_name.split(SEP)[0] if SEP in namespaced_name else "" + + +def get_resource_name(namespaced_name: str) -> str: + """Extract the resource name from a namespaced resource name""" + return namespaced_name.split(SEP, 1)[1] if SEP in namespaced_name else namespaced_name diff --git a/tests/integration/api/mcp_tools_server.py b/tests/integration/api/mcp_tools_server.py index 65c8fd927..0c0f70d96 100644 --- a/tests/integration/api/mcp_tools_server.py +++ b/tests/integration/api/mcp_tools_server.py @@ -13,6 +13,13 @@ # Create the FastMCP server app = FastMCP(name="An MCP Server", instructions="Here is how to use this server") +@app.prompt( + name="check_weather_prompt", + description="Asks for the weather in a specified location.", +) +def check_weather_prompt(location: str) -> str: + """The location to check""" + return f"Check the weather in {location}" @app.tool( name="check_weather", diff --git a/tests/integration/api/test_describe_a2a.py b/tests/integration/api/test_describe_a2a.py index 8d9c39d48..6dfadc333 100644 --- a/tests/integration/api/test_describe_a2a.py +++ b/tests/integration/api/test_describe_a2a.py @@ -2,6 +2,8 @@ import pytest +from fast_agent.mcp import SEP + if TYPE_CHECKING: from a2a.types import AgentCard, AgentSkill @@ -26,7 +28,7 @@ async def agent_function(): assert 3 == len(card.skills) skill: AgentSkill = card.skills[0] - assert "card_test-check_weather" == skill.id + assert f"card_test{SEP}check_weather" == skill.id assert "check_weather" == skill.name assert "Returns the weather for a specified location." assert skill.tags diff --git a/tests/integration/api/test_hyphens_in_name.py b/tests/integration/api/test_hyphens_in_name.py index 516b4542e..794dab7e4 100644 --- a/tests/integration/api/test_hyphens_in_name.py +++ b/tests/integration/api/test_hyphens_in_name.py @@ -1,5 +1,7 @@ import pytest +from fast_agent.mcp import SEP + @pytest.mark.integration @pytest.mark.asyncio @@ -9,6 +11,14 @@ async def test_hyphenated_server_name(fast_agent): @fast.agent(name="test", instruction="here are you instructions", servers=["hyphen-test"]) async def agent_function(): async with fast.run() as app: + # test prompt/get request + get_prompt_result = await app.test.get_prompt( + prompt_name=f"hyphen-test{SEP}check_weather_prompt", + arguments={"location": "New York"}, + ) + assert get_prompt_result.description + + # test tool calling result = await app.test.send('***CALL_TOOL check_weather {"location": "New York"}') assert "sunny" in result diff --git a/tests/integration/api/test_tool_list_change.py b/tests/integration/api/test_tool_list_change.py index 450ad3c03..c34bf48a6 100644 --- a/tests/integration/api/test_tool_list_change.py +++ b/tests/integration/api/test_tool_list_change.py @@ -4,7 +4,11 @@ import pytest +from fast_agent.mcp import SEP + if TYPE_CHECKING: + from mcp import ListToolsResult + from fast_agent.mcp.mcp_aggregator import NamespacedTool # Enable debug logging for the test @@ -22,11 +26,9 @@ async def agent_function(): print("Initializing agent") async with fast.run() as app: # Initially there should be one tool (check_weather) - tools_dict = await app.test.list_mcp_tools() - # Check that we have the dynamic_tool server - assert "dynamic_tool" in tools_dict - assert 1 == len(tools_dict["dynamic_tool"]) - assert "check_weather" == tools_dict["dynamic_tool"][0].name + tools: ListToolsResult = await app.test.list_tools() + assert 1 == len(tools.tools) + assert f"dynamic_tool{SEP}check_weather" == tools.tools[0].name # Calling check_weather will toggle the dynamic_tool and send a notification result = await app.test.send('***CALL_TOOL check_weather {"location": "New York"}') @@ -47,7 +49,6 @@ async def agent_function(): if tool.name == "dynamic_tool": dynamic_tool_found = True break - # Verify the dynamic tool was added assert dynamic_tool_found, ( "Dynamic tool was not added to the tool list after notification" diff --git a/tests/integration/mcp_filtering/test_mcp_filtering.py b/tests/integration/mcp_filtering/test_mcp_filtering.py index afd8d9b25..ebc1e8876 100644 --- a/tests/integration/mcp_filtering/test_mcp_filtering.py +++ b/tests/integration/mcp_filtering/test_mcp_filtering.py @@ -7,6 +7,7 @@ import pytest from fast_agent.agents import McpAgent +from fast_agent.mcp.common import SEP @pytest.mark.integration @@ -30,13 +31,13 @@ async def agent_no_filter(): # Should have all 7 tools expected_tools = { - "filtering_test_server-math_add", - "filtering_test_server-math_subtract", - "filtering_test_server-math_multiply", - "filtering_test_server-string_upper", - "filtering_test_server-string_lower", - "filtering_test_server-utility_ping", - "filtering_test_server-utility_status", + f"filtering_test_server{SEP}math_add", + f"filtering_test_server{SEP}math_subtract", + f"filtering_test_server{SEP}math_multiply", + f"filtering_test_server{SEP}string_upper", + f"filtering_test_server{SEP}string_lower", + f"filtering_test_server{SEP}utility_ping", + f"filtering_test_server{SEP}utility_status", } actual_tools = set(tool_names) assert actual_tools == expected_tools, f"Expected {expected_tools}, got {actual_tools}" @@ -58,19 +59,19 @@ async def agent_with_filter(): # Should have only math tools + string_upper expected_tools = { - "filtering_test_server-math_add", - "filtering_test_server-math_subtract", - "filtering_test_server-math_multiply", - "filtering_test_server-string_upper", + f"filtering_test_server{SEP}math_add", + f"filtering_test_server{SEP}math_subtract", + f"filtering_test_server{SEP}math_multiply", + f"filtering_test_server{SEP}string_upper", } actual_tools = set(tool_names) assert actual_tools == expected_tools, f"Expected {expected_tools}, got {actual_tools}" # Should NOT have these tools excluded_tools = { - "filtering_test_server-string_lower", - "filtering_test_server-utility_ping", - "filtering_test_server-utility_status", + f"filtering_test_server{SEP}string_lower", + f"filtering_test_server{SEP}utility_ping", + f"filtering_test_server{SEP}utility_status", } for tool_name in excluded_tools: assert tool_name not in tool_names, ( @@ -101,9 +102,9 @@ async def elapsed_agent(): ) elapsed = getattr(result, "transport_elapsed", None) - assert ( - elapsed is not None - ), "transport_elapsed should be attached to MCP CallToolResult responses" + assert elapsed is not None, ( + "transport_elapsed should be attached to MCP CallToolResult responses" + ) assert elapsed >= 0, "elapsed time should never be negative" assert elapsed < 120, "elapsed time should be within a credible bound" @@ -113,134 +114,159 @@ async def elapsed_agent(): @pytest.mark.integration @pytest.mark.asyncio @pytest.mark.e2e -async def test_resource_filtering_basic_agent(fast_agent): - """Test resource filtering with basic agent - no filtering vs with filtering""" +async def test_resource_filtering_returns_all_without_filters(fast_agent): + """Agents with no resource filters should expose every resource.""" fast = fast_agent - # Test 1: Agent without filtering - should have all resources @fast.agent( - name="agent_no_filter", + name="resource_no_filter_agent", instruction="Agent without resource filtering", model="passthrough", servers=["filtering_test_server"], ) - async def agent_no_filter(): + async def resource_no_filter_agent(): async with fast.run() as agent_app: - resources = await agent_app.agent_no_filter.list_resources() - resource_uris = resources["filtering_test_server"] # Already a list of URI strings - - # Should have all 4 resources - expected_resources = { + resources = await agent_app.resource_no_filter_agent.list_resources() + actual = set(resources["filtering_test_server"]) + expected = { "resource://math/constants", "resource://math/formulas", "resource://string/examples", "resource://utility/info", } - actual_resources = set(resource_uris) - assert actual_resources == expected_resources, ( - f"Expected {expected_resources}, got {actual_resources}" - ) + assert actual == expected, f"Expected {expected}, got {actual}" + + await resource_no_filter_agent() + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_resource_filtering_applies_patterns(fast_agent): + """Agents honour configured resource filters for a server.""" + fast = fast_agent - # Test 2: Agent with filtering - should have only filtered resources @fast.agent( - name="agent_with_filter", + name="resource_filtered_agent", instruction="Agent with resource filtering", model="passthrough", servers=["filtering_test_server"], resources={"filtering_test_server": ["resource://math/*", "resource://string/examples"]}, ) - async def agent_with_filter(): + async def resource_filtered_agent(): async with fast.run() as agent_app: - resources = await agent_app.agent_with_filter.list_resources() - resource_uris = resources.get( - "filtering_test_server", [] - ) # Get list or empty list if server not present - - # Should have only math resources + string examples - expected_resources = { + resources = await agent_app.resource_filtered_agent.list_resources() + actual = set(resources.get("filtering_test_server", [])) + expected = { "resource://math/constants", "resource://math/formulas", "resource://string/examples", } - actual_resources = set(resource_uris) - assert actual_resources == expected_resources, ( - f"Expected {expected_resources}, got {actual_resources}" - ) + assert actual == expected, f"Expected {expected}, got {actual}" + assert "resource://utility/info" not in actual - # Should NOT have utility resource - excluded_resources = {"resource://utility/info"} - for resource_uri in excluded_resources: - assert resource_uri not in resource_uris, ( - f"Resource {resource_uri} should have been filtered out" - ) + await resource_filtered_agent() - await agent_no_filter() - await agent_with_filter() + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_resource_filtering_ignores_irrelevant_filters(fast_agent): + """Filters on other namespaces must not affect the target server.""" + fast = fast_agent + + @fast.agent( + name="resource_irrelevant_filter_agent", + instruction="Agent with irrelevant resource filters", + model="passthrough", + servers=["filtering_test_server"], + resources={"other_server": ["resource://*"]}, + ) + async def resource_irrelevant_filter_agent(): + async with fast.run() as agent_app: + resources = await agent_app.resource_irrelevant_filter_agent.list_resources() + actual = set(resources["filtering_test_server"]) + expected = { + "resource://math/constants", + "resource://math/formulas", + "resource://string/examples", + "resource://utility/info", + } + assert actual == expected, f"Expected {expected}, got {actual}" + + await resource_irrelevant_filter_agent() @pytest.mark.integration @pytest.mark.asyncio @pytest.mark.e2e -async def test_prompt_filtering_basic_agent(fast_agent): - """Test prompt filtering with basic agent - no filtering vs with filtering""" +async def test_prompt_filtering_returns_all_without_filters(fast_agent): + """Agents with no prompt filters should expose every prompt.""" fast = fast_agent - # Test 1: Agent without filtering - should have all prompts @fast.agent( - name="agent_no_filter", + name="prompt_no_filter_agent", instruction="Agent without prompt filtering", model="passthrough", servers=["filtering_test_server"], ) - async def agent_no_filter(): + async def prompt_no_filter_agent(): async with fast.run() as agent_app: - prompts = await agent_app.agent_no_filter.list_prompts() - prompt_names = [prompt.name for prompt in prompts["filtering_test_server"]] - - # Should have all 4 prompts - expected_prompts = { - "math_helper", - "math_teacher", - "string_processor", - "utility_assistant", - } - actual_prompts = set(prompt_names) - assert actual_prompts == expected_prompts, ( - f"Expected {expected_prompts}, got {actual_prompts}" - ) + prompts = await agent_app.prompt_no_filter_agent.list_prompts() + actual = {prompt.name for prompt in prompts["filtering_test_server"]} + expected = {"math_helper", "math_teacher", "string_processor", "utility_assistant"} + assert actual == expected, f"Expected {expected}, got {actual}" + + await prompt_no_filter_agent() + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_prompt_filtering_applies_patterns(fast_agent): + """Agents honour configured prompt filters for a server.""" + fast = fast_agent - # Test 2: Agent with filtering - should have only filtered prompts @fast.agent( - name="agent_with_filter", + name="prompt_filtered_agent", instruction="Agent with prompt filtering", model="passthrough", servers=["filtering_test_server"], prompts={"filtering_test_server": ["math_*", "utility_assistant"]}, ) - async def agent_with_filter(): + async def prompt_filtered_agent(): async with fast.run() as agent_app: - prompts = await agent_app.agent_with_filter.list_prompts() - prompt_list = prompts.get( - "filtering_test_server", [] - ) # Get list or empty list if server not present - prompt_names = [prompt.name for prompt in prompt_list] + prompts = await agent_app.prompt_filtered_agent.list_prompts() + actual = {prompt.name for prompt in prompts.get("filtering_test_server", [])} + expected = {"math_helper", "math_teacher", "utility_assistant"} + assert actual == expected, f"Expected {expected}, got {actual}" + assert "string_processor" not in actual - # Should have only math prompts + utility_assistant - expected_prompts = {"math_helper", "math_teacher", "utility_assistant"} - actual_prompts = set(prompt_names) - assert actual_prompts == expected_prompts, ( - f"Expected {expected_prompts}, got {actual_prompts}" - ) + await prompt_filtered_agent() - # Should NOT have string_processor - excluded_prompts = {"string_processor"} - for prompt_name in excluded_prompts: - assert prompt_name not in prompt_names, ( - f"Prompt {prompt_name} should have been filtered out" - ) - await agent_no_filter() - await agent_with_filter() +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_prompt_filtering_ignores_irrelevant_filters(fast_agent): + """Filters on other namespaces must not affect prompts for the target server.""" + fast = fast_agent + + @fast.agent( + name="prompt_irrelevant_filter_agent", + instruction="Agent with irrelevant prompt filters", + model="passthrough", + servers=["filtering_test_server"], + prompts={"some_other_server": ["*"]}, + ) + async def prompt_irrelevant_filter_agent(): + async with fast.run() as agent_app: + prompts = await agent_app.prompt_irrelevant_filter_agent.list_prompts() + actual = {prompt.name for prompt in prompts["filtering_test_server"]} + expected = {"math_helper", "math_teacher", "string_processor", "utility_assistant"} + assert actual == expected, f"Expected {expected}, got {actual}" + + await prompt_irrelevant_filter_agent() @pytest.mark.integration @@ -267,19 +293,19 @@ async def custom_string_agent(): # Should have only string tools expected_tools = { - "filtering_test_server-string_upper", - "filtering_test_server-string_lower", + f"filtering_test_server{SEP}string_upper", + f"filtering_test_server{SEP}string_lower", } actual_tools = set(tool_names) assert actual_tools == expected_tools, f"Expected {expected_tools}, got {actual_tools}" # Should NOT have math or utility tools excluded_tools = { - "filtering_test_server-math_add", - "filtering_test_server-math_subtract", - "filtering_test_server-math_multiply", - "filtering_test_server-utility_ping", - "filtering_test_server-utility_status", + f"filtering_test_server{SEP}math_add", + f"filtering_test_server{SEP}math_subtract", + f"filtering_test_server{SEP}math_multiply", + f"filtering_test_server{SEP}utility_ping", + f"filtering_test_server{SEP}utility_status", } for tool_name in excluded_tools: assert tool_name not in tool_names, ( @@ -311,9 +337,9 @@ async def agent_combined_filter(): tools = await agent_app.agent_combined_filter.list_tools() tool_names = [tool.name for tool in tools.tools] expected_tools = { - "filtering_test_server-math_add", - "filtering_test_server-math_subtract", - "filtering_test_server-math_multiply", + f"filtering_test_server{SEP}math_add", + f"filtering_test_server{SEP}math_subtract", + f"filtering_test_server{SEP}math_multiply", } actual_tools = set(tool_names) assert actual_tools == expected_tools, ( diff --git a/tests/integration/roots/live.py b/tests/integration/roots/live.py index fc06707cd..7c91ad20b 100644 --- a/tests/integration/roots/live.py +++ b/tests/integration/roots/live.py @@ -1,6 +1,6 @@ import asyncio -from fast_agent import FastAgent +from fast_agent.mcp import SEP, FastAgent # Create the application fast = FastAgent("FastAgent Example") @@ -11,7 +11,7 @@ async def main(): # use the --model command line switch or agent arguments to change model async with fast.run() as agent: - await agent.send("***CALL_TOOL roots_test-show_roots {}") + await agent.send(f"***CALL_TOOL roots_test{SEP}show_roots {{}}") if __name__ == "__main__": diff --git a/tests/integration/roots/test_roots.py b/tests/integration/roots/test_roots.py index 2dddb9184..405d59972 100644 --- a/tests/integration/roots/test_roots.py +++ b/tests/integration/roots/test_roots.py @@ -1,5 +1,7 @@ import pytest +from fast_agent.mcp import SEP + @pytest.mark.integration @pytest.mark.asyncio @@ -12,7 +14,7 @@ async def test_roots_returned(fast_agent): @fast.agent(name="foo", instruction="bar", servers=["roots_test"]) async def agent_function(): async with fast.run() as agent: - result = await agent.foo.send("***CALL_TOOL roots_test-show_roots {}") + result = await agent.foo.send(f"***CALL_TOOL roots_test{SEP}show_roots {{}}") assert "file:///mnt/data/" in result # alias assert "test_data" in result assert "file://no/alias" in result # no alias. diff --git a/tests/integration/sampling/live.py b/tests/integration/sampling/live.py index 0a1494151..27a69a337 100644 --- a/tests/integration/sampling/live.py +++ b/tests/integration/sampling/live.py @@ -1,9 +1,9 @@ import asyncio -from fast_agent import FastAgent +from fast_agent.mcp import SEP, FastAgent # Create the application with specified model -fast = FastAgent("FastAgent Example") +fast = FastAgent("fast-agent Example") # Define the agent @@ -11,10 +11,10 @@ async def main(): # use the --model command line switch or agent arguments to change model async with fast.run() as agent: - result = await agent.send('***CALL_TOOL sampling_test-sample {"to_sample": "123foo"}') + result = await agent.send(f'***CALL_TOOL sampling_test{SEP}sample {"to_sample": "123foo"}') print(f"RESULT: {result}") - result = await agent.send("***CALL_TOOL slow_sampling-sample_parallel") + result = await agent.send(f"***CALL_TOOL slow_sampling{SEP}sample_parallel") print(f"RESULT: {result}")