chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from mcp import Tool as MCPTool
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
Content,
|
||||
GetPromptResult,
|
||||
ListPromptsResult,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
PromptMessage,
|
||||
ReadResourceResult,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from agents.mcp import MCPServer
|
||||
from agents.mcp.server import _UNSET, _MCPServerWithClientSession, _UnsetType
|
||||
from agents.mcp.util import MCPToolCustomDataExtractor, MCPToolMetaResolver, ToolFilter
|
||||
from agents.tool import ToolErrorFunction
|
||||
|
||||
tee = shutil.which("tee") or ""
|
||||
assert tee, "tee not found"
|
||||
|
||||
|
||||
# Added dummy stream classes for patching stdio_client to avoid real I/O during tests
|
||||
class DummyStream:
|
||||
async def send(self, msg):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
raise Exception("Dummy receive not implemented")
|
||||
|
||||
|
||||
class DummyStreamsContextManager:
|
||||
async def __aenter__(self):
|
||||
return (DummyStream(), DummyStream())
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
class _TestFilterServer(_MCPServerWithClientSession):
|
||||
"""Minimal implementation of _MCPServerWithClientSession for testing tool filtering"""
|
||||
|
||||
def __init__(self, tool_filter: ToolFilter, server_name: str):
|
||||
# Initialize parent class properly to avoid type errors
|
||||
super().__init__(
|
||||
cache_tools_list=False,
|
||||
client_session_timeout_seconds=None,
|
||||
tool_filter=tool_filter,
|
||||
)
|
||||
self._server_name: str = server_name
|
||||
# Override some attributes for test isolation
|
||||
self.session = None
|
||||
self._cleanup_lock = asyncio.Lock()
|
||||
|
||||
def create_streams(self):
|
||||
raise NotImplementedError("Not needed for filtering tests")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._server_name
|
||||
|
||||
|
||||
class FakeMCPServer(MCPServer):
|
||||
def __init__(
|
||||
self,
|
||||
tools: list[MCPTool] | None = None,
|
||||
tool_filter: ToolFilter = None,
|
||||
server_name: str = "fake_mcp_server",
|
||||
require_approval: object | None = None,
|
||||
failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET,
|
||||
tool_meta_resolver: MCPToolMetaResolver | None = None,
|
||||
custom_data_extractor: MCPToolCustomDataExtractor | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
use_structured_content=False,
|
||||
require_approval=require_approval, # type: ignore[arg-type]
|
||||
failure_error_function=failure_error_function,
|
||||
tool_meta_resolver=tool_meta_resolver,
|
||||
custom_data_extractor=custom_data_extractor,
|
||||
)
|
||||
self.tools: list[MCPTool] = tools or []
|
||||
self.tool_calls: list[str] = []
|
||||
self.tool_results: list[str] = []
|
||||
self.tool_metas: list[dict[str, Any] | None] = []
|
||||
self.tool_filter = tool_filter
|
||||
self._server_name = server_name
|
||||
self._custom_content: list[Content] | None = None
|
||||
self._response_meta: dict[str, Any] | None = None
|
||||
|
||||
def add_tool(self, name: str, input_schema: dict[str, Any]):
|
||||
self.tools.append(MCPTool(name=name, inputSchema=input_schema))
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def cleanup(self):
|
||||
pass
|
||||
|
||||
async def list_tools(self, run_context=None, agent=None):
|
||||
tools = self.tools
|
||||
|
||||
# Apply tool filtering using the REAL implementation
|
||||
if self.tool_filter is not None:
|
||||
# Use the real _MCPServerWithClientSession filtering logic
|
||||
filter_server = _TestFilterServer(self.tool_filter, self.name)
|
||||
tools = await filter_server._apply_tool_filter(tools, run_context, agent)
|
||||
|
||||
return tools
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> CallToolResult:
|
||||
self.tool_calls.append(tool_name)
|
||||
self.tool_results.append(f"result_{tool_name}_{json.dumps(arguments)}")
|
||||
self.tool_metas.append(meta)
|
||||
|
||||
# Allow testing custom content scenarios
|
||||
if self._custom_content is not None:
|
||||
return CallToolResult(content=self._custom_content)
|
||||
|
||||
return CallToolResult(
|
||||
content=[TextContent(text=self.tool_results[-1], type="text")],
|
||||
_meta=self._response_meta,
|
||||
)
|
||||
|
||||
async def list_prompts(self, run_context=None, agent=None) -> ListPromptsResult:
|
||||
"""Return empty list of prompts for fake server"""
|
||||
return ListPromptsResult(prompts=[])
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
"""Return a simple prompt result for fake server"""
|
||||
content = f"Fake prompt content for {name}"
|
||||
message = PromptMessage(role="user", content=TextContent(type="text", text=content))
|
||||
return GetPromptResult(description=f"Fake prompt: {name}", messages=[message])
|
||||
|
||||
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
||||
"""Return empty list of resources for fake server."""
|
||||
return ListResourcesResult(resources=[])
|
||||
|
||||
async def list_resource_templates(
|
||||
self, cursor: str | None = None
|
||||
) -> ListResourceTemplatesResult:
|
||||
"""Return empty list of resource templates for fake server."""
|
||||
return ListResourceTemplatesResult(resourceTemplates=[])
|
||||
|
||||
async def read_resource(self, uri: str) -> ReadResourceResult:
|
||||
"""Return empty resource contents for fake server."""
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._server_name
|
||||
@@ -0,0 +1,63 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from mcp.types import ListToolsResult, Tool as MCPTool
|
||||
|
||||
from agents import Agent
|
||||
from agents.mcp import MCPServerStdio
|
||||
from agents.run_context import RunContextWrapper
|
||||
|
||||
from .helpers import DummyStreamsContextManager, tee
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager())
|
||||
@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None)
|
||||
@patch("mcp.client.session.ClientSession.list_tools")
|
||||
async def test_server_caching_works(
|
||||
mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client
|
||||
):
|
||||
"""Test that if we turn caching on, the list of tools is cached and not fetched from the server
|
||||
on each call to `list_tools()`.
|
||||
"""
|
||||
server = MCPServerStdio(
|
||||
params={
|
||||
"command": tee,
|
||||
},
|
||||
cache_tools_list=True,
|
||||
)
|
||||
|
||||
tools = [
|
||||
MCPTool(name="tool1", inputSchema={}),
|
||||
MCPTool(name="tool2", inputSchema={}),
|
||||
]
|
||||
|
||||
mock_list_tools.return_value = ListToolsResult(tools=tools)
|
||||
|
||||
async with server:
|
||||
# Create test context and agent
|
||||
run_context = RunContextWrapper(context=None)
|
||||
agent = Agent(name="test_agent", instructions="Test agent")
|
||||
|
||||
# Call list_tools() multiple times
|
||||
result_tools = await server.list_tools(run_context, agent)
|
||||
assert result_tools == tools
|
||||
|
||||
assert mock_list_tools.call_count == 1, "list_tools() should have been called once"
|
||||
|
||||
# Call list_tools() again, should return the cached value
|
||||
result_tools = await server.list_tools(run_context, agent)
|
||||
assert result_tools == tools
|
||||
|
||||
assert mock_list_tools.call_count == 1, "list_tools() should not have been called again"
|
||||
|
||||
# Invalidate the cache and call list_tools() again
|
||||
server.invalidate_tools_cache()
|
||||
result_tools = await server.list_tools(run_context, agent)
|
||||
assert result_tools == tools
|
||||
|
||||
assert mock_list_tools.call_count == 2, "list_tools() should be called again"
|
||||
|
||||
# Without invalidating the cache, calling list_tools() again should return the cached value
|
||||
result_tools = await server.list_tools(run_context, agent)
|
||||
assert result_tools == tools
|
||||
@@ -0,0 +1,574 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import ClientSession, Tool as MCPTool
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import CallToolResult, ErrorData, GetPromptResult, ListPromptsResult, ListToolsResult
|
||||
|
||||
from agents.exceptions import UserError
|
||||
from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports]
|
||||
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, fail_call_tool: int = 0, fail_list_tools: int = 0):
|
||||
self.fail_call_tool = fail_call_tool
|
||||
self.fail_list_tools = fail_list_tools
|
||||
self.call_tool_attempts = 0
|
||||
self.list_tools_attempts = 0
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
self.call_tool_attempts += 1
|
||||
if self.call_tool_attempts <= self.fail_call_tool:
|
||||
raise RuntimeError("call_tool failure")
|
||||
return CallToolResult(content=[])
|
||||
|
||||
async def list_tools(self):
|
||||
self.list_tools_attempts += 1
|
||||
if self.list_tools_attempts <= self.fail_list_tools:
|
||||
raise RuntimeError("list_tools failure")
|
||||
return ListToolsResult(tools=[MCPTool(name="tool", inputSchema={})])
|
||||
|
||||
|
||||
class DummyServer(_MCPServerWithClientSession):
|
||||
def __init__(self, session: DummySession, retries: int, *, serialize_requests: bool = False):
|
||||
super().__init__(
|
||||
cache_tools_list=False,
|
||||
client_session_timeout_seconds=None,
|
||||
max_retry_attempts=retries,
|
||||
retry_backoff_seconds_base=0,
|
||||
)
|
||||
self.session = cast(ClientSession, session)
|
||||
self._serialize_session_requests = serialize_requests
|
||||
|
||||
def create_streams(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "dummy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_retries_until_success():
|
||||
session = DummySession(fail_call_tool=2)
|
||||
server = DummyServer(session=session, retries=2)
|
||||
result = await server.call_tool("tool", None)
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert session.call_tool_attempts == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_unlimited_retries():
|
||||
session = DummySession(fail_list_tools=3)
|
||||
server = DummyServer(session=session, retries=-1)
|
||||
tools = await server.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "tool"
|
||||
assert session.list_tools_attempts == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_validates_required_parameters_before_remote_call():
|
||||
session = DummySession()
|
||||
server = DummyServer(session=session, retries=0)
|
||||
server._tools_list = [ # noqa: SLF001
|
||||
MCPTool(
|
||||
name="tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"param_a": {"type": "string"}},
|
||||
"required": ["param_a"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
with pytest.raises(UserError, match="missing required parameters: param_a"):
|
||||
await server.call_tool("tool", {})
|
||||
|
||||
assert session.call_tool_attempts == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_with_required_parameters_still_calls_remote_tool():
|
||||
session = DummySession()
|
||||
server = DummyServer(session=session, retries=0)
|
||||
server._tools_list = [ # noqa: SLF001
|
||||
MCPTool(
|
||||
name="tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"param_a": {"type": "string"}},
|
||||
"required": ["param_a"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
result = await server.call_tool("tool", {"param_a": "value"})
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_skips_validation_when_tool_is_missing_from_cache():
|
||||
session = DummySession()
|
||||
server = DummyServer(session=session, retries=0)
|
||||
server._tools_list = [MCPTool(name="different_tool", inputSchema={"required": ["param_a"]})] # noqa: SLF001
|
||||
|
||||
await server.call_tool("tool", {})
|
||||
assert session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_skips_validation_when_required_list_is_absent():
|
||||
session = DummySession()
|
||||
server = DummyServer(session=session, retries=0)
|
||||
server._tools_list = [MCPTool(name="tool", inputSchema={"type": "object"})] # noqa: SLF001
|
||||
|
||||
await server.call_tool("tool", None)
|
||||
assert session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_validates_required_parameters_when_arguments_is_none():
|
||||
session = DummySession()
|
||||
server = DummyServer(session=session, retries=0)
|
||||
server._tools_list = [MCPTool(name="tool", inputSchema={"required": ["param_a"]})] # noqa: SLF001
|
||||
|
||||
with pytest.raises(UserError, match="missing required parameters: param_a"):
|
||||
await server.call_tool("tool", None)
|
||||
|
||||
assert session.call_tool_attempts == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_rejects_non_object_arguments_before_remote_call():
|
||||
session = DummySession()
|
||||
server = DummyServer(session=session, retries=0)
|
||||
server._tools_list = [MCPTool(name="tool", inputSchema={"required": ["param_a"]})] # noqa: SLF001
|
||||
|
||||
with pytest.raises(UserError, match="arguments must be an object"):
|
||||
await server.call_tool("tool", cast(dict[str, object] | None, ["bad"]))
|
||||
|
||||
assert session.call_tool_attempts == 0
|
||||
|
||||
|
||||
class ConcurrentCancellationSession:
|
||||
def __init__(self):
|
||||
self._slow_task: asyncio.Task[CallToolResult] | None = None
|
||||
self._slow_started = asyncio.Event()
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
if tool_name == "slow":
|
||||
self._slow_task = cast(asyncio.Task[CallToolResult], asyncio.current_task())
|
||||
self._slow_started.set()
|
||||
await asyncio.sleep(0.1)
|
||||
return CallToolResult(content=[])
|
||||
|
||||
await self._slow_started.wait()
|
||||
assert self._slow_task is not None
|
||||
self._slow_task.cancel()
|
||||
raise RuntimeError("synthetic request failure")
|
||||
|
||||
|
||||
class CancelledToolSession:
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
raise asyncio.CancelledError("synthetic call cancellation")
|
||||
|
||||
|
||||
class MixedExceptionGroupSession:
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
req = httpx.Request("POST", "https://example.test/mcp")
|
||||
resp = httpx.Response(401, request=req)
|
||||
raise BaseExceptionGroup(
|
||||
"mixed request failure",
|
||||
[
|
||||
asyncio.CancelledError("synthetic call cancellation"),
|
||||
httpx.HTTPStatusError("HTTP error 401", request=req, response=resp),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class SharedHttpStatusSession:
|
||||
def __init__(self, status_code: int):
|
||||
self.status_code = status_code
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
req = httpx.Request("POST", "https://example.test/mcp")
|
||||
resp = httpx.Response(self.status_code, request=req)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP error {self.status_code}",
|
||||
request=req,
|
||||
response=resp,
|
||||
)
|
||||
|
||||
|
||||
class TimeoutSession:
|
||||
def __init__(self, message: str = "timed out"):
|
||||
self.call_tool_attempts = 0
|
||||
self.message = message
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
self.call_tool_attempts += 1
|
||||
raise httpx.TimeoutException(self.message)
|
||||
|
||||
|
||||
class ClosedResourceSession:
|
||||
def __init__(self):
|
||||
self.call_tool_attempts = 0
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
self.call_tool_attempts += 1
|
||||
raise ClosedResourceError()
|
||||
|
||||
|
||||
class McpRequestTimeoutSession:
|
||||
def __init__(self, message: str = "timed out"):
|
||||
self.call_tool_attempts = 0
|
||||
self.message = message
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
self.call_tool_attempts += 1
|
||||
raise McpError(
|
||||
ErrorData(code=httpx.codes.REQUEST_TIMEOUT, message=self.message),
|
||||
)
|
||||
|
||||
|
||||
class IsolatedRetrySession:
|
||||
def __init__(self):
|
||||
self.call_tool_attempts = 0
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
self.call_tool_attempts += 1
|
||||
return CallToolResult(content=[])
|
||||
|
||||
|
||||
class HangingSession:
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
class DummyStreamableHttpServer(MCPServerStreamableHttp):
|
||||
def __init__(self, shared_session: object, isolated_session: object):
|
||||
super().__init__(
|
||||
params={"url": "https://example.test/mcp"},
|
||||
client_session_timeout_seconds=None,
|
||||
max_retry_attempts=0,
|
||||
)
|
||||
self.session = cast(ClientSession, shared_session)
|
||||
self._isolated_session = cast(ClientSession, isolated_session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _isolated_client_session(self):
|
||||
yield self._isolated_session
|
||||
|
||||
async def list_tools(self, run_context=None, agent=None):
|
||||
return [MCPTool(name="tool", inputSchema={})]
|
||||
|
||||
async def list_prompts(self):
|
||||
return ListPromptsResult(prompts=[])
|
||||
|
||||
async def get_prompt(self, name, arguments=None):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class IsolatedSessionEnterFailure:
|
||||
def __init__(self, server: "EnterFailingStreamableHttpServer", message: str):
|
||||
self.server = server
|
||||
self.message = message
|
||||
|
||||
async def __aenter__(self):
|
||||
self.server.isolated_enter_attempts += 1
|
||||
raise httpx.TimeoutException(self.message)
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class EnterFailingStreamableHttpServer(DummyStreamableHttpServer):
|
||||
def __init__(self, shared_session: object, *, isolated_message: str):
|
||||
super().__init__(shared_session, IsolatedRetrySession())
|
||||
self.isolated_enter_attempts = 0
|
||||
self._isolated_message = isolated_message
|
||||
|
||||
def _isolated_client_session(self):
|
||||
return IsolatedSessionEnterFailure(self, self._isolated_message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_retries_cancelled_request_on_isolated_session():
|
||||
shared_session = CancelledToolSession()
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(shared_session, isolated_session)
|
||||
server.max_retry_attempts = 1
|
||||
|
||||
result = await server.call_tool("tool", None)
|
||||
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert isolated_session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_retries_5xx_on_isolated_session():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(SharedHttpStatusSession(504), isolated_session)
|
||||
server.max_retry_attempts = 1
|
||||
|
||||
result = await server.call_tool("tool", None)
|
||||
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert isolated_session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_retries_closed_resource_on_isolated_session():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(ClosedResourceSession(), isolated_session)
|
||||
server.max_retry_attempts = 1
|
||||
|
||||
result = await server.call_tool("tool", None)
|
||||
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert isolated_session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_retries_mcp_408_on_isolated_session():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(
|
||||
McpRequestTimeoutSession("Timed out while waiting for response to ClientRequest."),
|
||||
isolated_session,
|
||||
)
|
||||
server.max_retry_attempts = 1
|
||||
|
||||
result = await server.call_tool("tool", None)
|
||||
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert isolated_session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_does_not_retry_4xx_on_isolated_session():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(SharedHttpStatusSession(401), isolated_session)
|
||||
|
||||
with pytest.raises(UserError, match="HTTP error 401"):
|
||||
await server.call_tool("tool", None)
|
||||
|
||||
assert isolated_session.call_tool_attempts == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_does_not_isolated_retry_without_retry_budget():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(CancelledToolSession(), isolated_session)
|
||||
server.max_retry_attempts = 0
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await server.call_tool("tool", None)
|
||||
|
||||
assert isolated_session.call_tool_attempts == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_counts_isolated_retry_against_retry_budget():
|
||||
shared_session = TimeoutSession("shared timed out")
|
||||
isolated_session = TimeoutSession("isolated timed out")
|
||||
server = DummyStreamableHttpServer(shared_session, isolated_session)
|
||||
server.max_retry_attempts = 2
|
||||
|
||||
with pytest.raises(httpx.TimeoutException, match="shared timed out"):
|
||||
await server.call_tool("tool", None)
|
||||
|
||||
assert shared_session.call_tool_attempts == 2
|
||||
assert isolated_session.call_tool_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_counts_isolated_session_setup_failure_against_retry_budget():
|
||||
shared_session = TimeoutSession("shared timed out")
|
||||
server = EnterFailingStreamableHttpServer(
|
||||
shared_session,
|
||||
isolated_message="isolated setup timed out",
|
||||
)
|
||||
server.max_retry_attempts = 2
|
||||
|
||||
with pytest.raises(httpx.TimeoutException, match="shared timed out"):
|
||||
await server.call_tool("tool", None)
|
||||
|
||||
assert shared_session.call_tool_attempts == 2
|
||||
assert server.isolated_enter_attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_does_not_retry_mixed_exception_groups():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(MixedExceptionGroupSession(), isolated_session)
|
||||
server.max_retry_attempts = 1
|
||||
|
||||
with pytest.raises(UserError, match="HTTP error 401"):
|
||||
await server.call_tool("tool", None)
|
||||
|
||||
assert isolated_session.call_tool_attempts == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_preserves_outer_cancellation():
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyStreamableHttpServer(HangingSession(), isolated_session)
|
||||
|
||||
task = asyncio.create_task(server.call_tool("slow", None))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert isolated_session.call_tool_attempts == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_preserves_outer_cancellation_during_isolated_retry():
|
||||
server = DummyStreamableHttpServer(CancelledToolSession(), HangingSession())
|
||||
server.max_retry_attempts = 1
|
||||
|
||||
task = asyncio.create_task(server.call_tool("tool", None))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
class ConcurrentPromptCancellationSession(ConcurrentCancellationSession):
|
||||
async def list_tools(self):
|
||||
return ListToolsResult(tools=[MCPTool(name="tool", inputSchema={})])
|
||||
|
||||
async def list_prompts(self):
|
||||
await self._slow_started.wait()
|
||||
assert self._slow_task is not None
|
||||
self._slow_task.cancel()
|
||||
raise RuntimeError("synthetic request failure")
|
||||
|
||||
async def get_prompt(self, name, arguments=None):
|
||||
await self._slow_started.wait()
|
||||
assert self._slow_task is not None
|
||||
self._slow_task.cancel()
|
||||
raise RuntimeError("synthetic request failure")
|
||||
|
||||
|
||||
class OverlapTrackingSession:
|
||||
def __init__(self):
|
||||
self.in_flight = 0
|
||||
self.max_in_flight = 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def _enter_request(self):
|
||||
self.in_flight += 1
|
||||
self.max_in_flight = max(self.max_in_flight, self.in_flight)
|
||||
try:
|
||||
await asyncio.sleep(0.02)
|
||||
yield
|
||||
finally:
|
||||
self.in_flight -= 1
|
||||
|
||||
async def call_tool(self, tool_name, arguments, meta=None):
|
||||
async with self._enter_request():
|
||||
return CallToolResult(content=[])
|
||||
|
||||
async def list_prompts(self):
|
||||
async with self._enter_request():
|
||||
return ListPromptsResult(prompts=[])
|
||||
|
||||
async def get_prompt(self, name, arguments=None):
|
||||
async with self._enter_request():
|
||||
return GetPromptResult(
|
||||
description=None,
|
||||
messages=[],
|
||||
)
|
||||
|
||||
|
||||
class DummyPromptStreamableHttpServer(DummyStreamableHttpServer):
|
||||
def __init__(
|
||||
self,
|
||||
shared_session: OverlapTrackingSession,
|
||||
isolated_session: IsolatedRetrySession,
|
||||
):
|
||||
super().__init__(shared_session, isolated_session)
|
||||
self.session = cast(ClientSession, shared_session)
|
||||
|
||||
async def list_prompts(self):
|
||||
session = self.session
|
||||
assert session is not None
|
||||
return await self._maybe_serialize_request(lambda: session.list_prompts())
|
||||
|
||||
async def get_prompt(self, name, arguments=None):
|
||||
session = self.session
|
||||
assert session is not None
|
||||
return await self._maybe_serialize_request(lambda: session.get_prompt(name, arguments))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serialized_session_requests_prevent_sibling_cancellation():
|
||||
session = ConcurrentPromptCancellationSession()
|
||||
server = DummyServer(session=cast(DummySession, session), retries=0, serialize_requests=True)
|
||||
|
||||
results = await asyncio.gather(
|
||||
server.call_tool("slow", None),
|
||||
server.call_tool("fail", None),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert isinstance(results[0], CallToolResult)
|
||||
assert isinstance(results[1], RuntimeError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prompt_method", ["list_prompts", "get_prompt"])
|
||||
async def test_serialized_prompt_requests_prevent_tool_cancellation(prompt_method: str):
|
||||
session = ConcurrentPromptCancellationSession()
|
||||
server = DummyServer(session=cast(DummySession, session), retries=0, serialize_requests=True)
|
||||
|
||||
prompt_request = (
|
||||
server.list_prompts() if prompt_method == "list_prompts" else server.get_prompt("prompt")
|
||||
)
|
||||
results = await asyncio.gather(
|
||||
server.call_tool("slow", None),
|
||||
prompt_request,
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert isinstance(results[0], CallToolResult)
|
||||
assert isinstance(results[1], RuntimeError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prompt_method", ["list_prompts", "get_prompt"])
|
||||
async def test_streamable_http_serializes_call_tool_with_prompt_requests(prompt_method: str):
|
||||
shared_session = OverlapTrackingSession()
|
||||
isolated_session = IsolatedRetrySession()
|
||||
server = DummyPromptStreamableHttpServer(shared_session, isolated_session)
|
||||
|
||||
prompt_request = (
|
||||
server.list_prompts() if prompt_method == "list_prompts" else server.get_prompt("prompt")
|
||||
)
|
||||
results = await asyncio.gather(
|
||||
server.call_tool("slow", None),
|
||||
prompt_request,
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert isinstance(results[0], CallToolResult)
|
||||
if prompt_method == "list_prompts":
|
||||
assert isinstance(results[1], ListPromptsResult)
|
||||
else:
|
||||
assert isinstance(results[1], GetPromptResult)
|
||||
assert shared_session.max_in_flight == 1
|
||||
assert isolated_session.call_tool_attempts == 0
|
||||
@@ -0,0 +1,69 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from mcp.types import ListToolsResult, Tool as MCPTool
|
||||
|
||||
from agents.mcp import MCPServerStdio
|
||||
|
||||
from .helpers import DummyStreamsContextManager, tee
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager())
|
||||
@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None)
|
||||
@patch("mcp.client.session.ClientSession.list_tools")
|
||||
async def test_async_ctx_manager_works(
|
||||
mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client
|
||||
):
|
||||
"""Test that the async context manager works."""
|
||||
server = MCPServerStdio(
|
||||
params={
|
||||
"command": tee,
|
||||
},
|
||||
cache_tools_list=True,
|
||||
)
|
||||
|
||||
tools = [
|
||||
MCPTool(name="tool1", inputSchema={}),
|
||||
MCPTool(name="tool2", inputSchema={}),
|
||||
]
|
||||
|
||||
mock_list_tools.return_value = ListToolsResult(tools=tools)
|
||||
|
||||
assert server.session is None, "Server should not be connected"
|
||||
|
||||
async with server:
|
||||
assert server.session is not None, "Server should be connected"
|
||||
|
||||
assert server.session is None, "Server should be disconnected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager())
|
||||
@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None)
|
||||
@patch("mcp.client.session.ClientSession.list_tools")
|
||||
async def test_manual_connect_disconnect_works(
|
||||
mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client
|
||||
):
|
||||
"""Test that the async context manager works."""
|
||||
server = MCPServerStdio(
|
||||
params={
|
||||
"command": tee,
|
||||
},
|
||||
cache_tools_list=True,
|
||||
)
|
||||
|
||||
tools = [
|
||||
MCPTool(name="tool1", inputSchema={}),
|
||||
MCPTool(name="tool2", inputSchema={}),
|
||||
]
|
||||
|
||||
mock_list_tools.return_value = ListToolsResult(tools=tools)
|
||||
|
||||
assert server.session is None, "Server should not be connected"
|
||||
|
||||
await server.connect()
|
||||
assert server.session is not None, "Server should be connected"
|
||||
|
||||
await server.cleanup()
|
||||
assert server.session is None, "Server should be disconnected"
|
||||
@@ -0,0 +1,242 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from agents import Agent, RunContextWrapper, Runner
|
||||
from agents.exceptions import UserError
|
||||
|
||||
from ..fake_model import FakeModel
|
||||
from ..test_responses import get_function_tool_call, get_text_message
|
||||
from ..utils.hitl import queue_function_call_and_text, resume_after_first_approval
|
||||
from .helpers import FakeMCPServer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_require_approval_pauses_and_resumes():
|
||||
"""MCP servers should honor require_approval for non-hosted tools."""
|
||||
|
||||
server = FakeMCPServer(require_approval="always")
|
||||
server.add_tool("add", {"type": "object", "properties": {}})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("add", "{}"),
|
||||
followup=[get_text_message("done")],
|
||||
)
|
||||
|
||||
first = await Runner.run(agent, "call add")
|
||||
|
||||
assert first.interruptions, "MCP tool should request approval"
|
||||
assert first.interruptions[0].tool_name == "add"
|
||||
|
||||
resumed = await resume_after_first_approval(agent, first, always_approve=True)
|
||||
|
||||
assert not resumed.interruptions
|
||||
assert server.tool_calls == ["add"]
|
||||
assert resumed.final_output == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_require_approval_tool_lists():
|
||||
"""TS-style requireApproval toolNames should map to needs_approval."""
|
||||
|
||||
require_approval: dict[str, object] = {
|
||||
"always": {"tool_names": ["add"]},
|
||||
"never": {"tool_names": ["noop"]},
|
||||
}
|
||||
server = FakeMCPServer(require_approval=require_approval)
|
||||
server.add_tool("add", {"type": "object", "properties": {}})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("add", "{}"),
|
||||
followup=[get_text_message("done")],
|
||||
)
|
||||
|
||||
first = await Runner.run(agent, "call add")
|
||||
assert first.interruptions, "add should require approval via require_approval toolNames"
|
||||
|
||||
resumed = await resume_after_first_approval(agent, first, always_approve=True)
|
||||
assert resumed.final_output == "done"
|
||||
assert server.tool_calls == ["add"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_require_approval_tool_mapping():
|
||||
"""Tool-name require_approval mappings should map to needs_approval."""
|
||||
|
||||
require_approval = {"add": "always", "noop": "never"}
|
||||
server = FakeMCPServer(require_approval=require_approval)
|
||||
server.add_tool("add", {"type": "object", "properties": {}})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("add", "{}"),
|
||||
followup=[get_text_message("done")],
|
||||
)
|
||||
|
||||
first = await Runner.run(agent, "call add")
|
||||
assert first.interruptions, "add should require approval via require_approval mapping"
|
||||
|
||||
resumed = await resume_after_first_approval(agent, first, always_approve=True)
|
||||
assert resumed.final_output == "done"
|
||||
assert server.tool_calls == ["add"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_require_approval_mapping_allows_policy_keyword_tool_names():
|
||||
"""Tool-name mappings should treat literal 'always'/'never' as tool names."""
|
||||
|
||||
require_approval = {"always": "always", "never": "never"}
|
||||
server = FakeMCPServer(require_approval=require_approval)
|
||||
server.add_tool("always", {"type": "object", "properties": {}})
|
||||
server.add_tool("never", {"type": "object", "properties": {}})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("always", "{}"),
|
||||
followup=[get_text_message("done")],
|
||||
)
|
||||
|
||||
first = await Runner.run(agent, "call always")
|
||||
assert first.interruptions, "tool named 'always' should require approval"
|
||||
assert first.interruptions[0].tool_name == "always"
|
||||
|
||||
resumed = await resume_after_first_approval(agent, first, always_approve=True)
|
||||
assert resumed.final_output == "done"
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("never", "{}"),
|
||||
followup=[get_text_message("done")],
|
||||
)
|
||||
|
||||
second = await Runner.run(agent, "call never")
|
||||
assert not second.interruptions, "tool named 'never' should not require approval"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("require_approval", "message"),
|
||||
[
|
||||
("alwyas", "expected 'always' or 'never'"),
|
||||
({"delete": "alwyas"}, "delete"),
|
||||
(
|
||||
{
|
||||
"always": {"tool_names": ["delete"]},
|
||||
"never": {"tool_names": ["delete"]},
|
||||
},
|
||||
"both always and never",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_mcp_require_approval_rejects_invalid_fail_open_policies(require_approval, message):
|
||||
"""Invalid MCP approval policies should not silently disable approvals."""
|
||||
|
||||
with pytest.raises(UserError, match=message):
|
||||
FakeMCPServer(require_approval=require_approval)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_require_approval_callable_can_allow_and_block_by_tool_name():
|
||||
"""Callable policies should decide approval dynamically for each MCP tool."""
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
def require_approval(
|
||||
_run_context: RunContextWrapper[object | None],
|
||||
_agent: Agent,
|
||||
tool: MCPTool,
|
||||
) -> bool:
|
||||
seen.append(tool.name)
|
||||
return tool.name == "guarded"
|
||||
|
||||
server = FakeMCPServer(require_approval=require_approval)
|
||||
server.add_tool("guarded", {"type": "object", "properties": {}})
|
||||
server.add_tool("safe", {"type": "object", "properties": {}})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("guarded", "{}"),
|
||||
followup=[get_text_message("guarded done")],
|
||||
)
|
||||
first = await Runner.run(agent, "call guarded")
|
||||
assert first.interruptions, "guarded should require approval via callable policy"
|
||||
assert first.interruptions[0].tool_name == "guarded"
|
||||
|
||||
resumed = await resume_after_first_approval(agent, first, always_approve=True)
|
||||
assert resumed.final_output == "guarded done"
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("safe", "{}"),
|
||||
followup=[get_text_message("safe done")],
|
||||
)
|
||||
second = await Runner.run(agent, "call safe")
|
||||
assert not second.interruptions, "safe should bypass approval via callable policy"
|
||||
assert second.final_output == "safe done"
|
||||
|
||||
assert seen == ["guarded", "guarded", "safe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_require_approval_async_callable_uses_run_context():
|
||||
"""Async callable policies should receive the run context and be awaited."""
|
||||
|
||||
seen_contexts: list[object | None] = []
|
||||
|
||||
async def require_approval(
|
||||
run_context: RunContextWrapper[dict[str, bool] | None],
|
||||
_agent: Agent,
|
||||
_tool,
|
||||
) -> bool:
|
||||
seen_contexts.append(run_context.context)
|
||||
await asyncio.sleep(0)
|
||||
return bool(run_context.context and run_context.context.get("needs_approval"))
|
||||
|
||||
server = FakeMCPServer(require_approval=require_approval)
|
||||
server.add_tool("conditional", {"type": "object", "properties": {}})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("conditional", "{}"),
|
||||
followup=[get_text_message("approved path")],
|
||||
)
|
||||
first = await Runner.run(agent, "call conditional", context={"needs_approval": True})
|
||||
assert first.interruptions, "run context should be able to trigger approval"
|
||||
|
||||
resumed = await resume_after_first_approval(agent, first, always_approve=True)
|
||||
assert resumed.final_output == "approved path"
|
||||
|
||||
queue_function_call_and_text(
|
||||
model,
|
||||
get_function_tool_call("conditional", "{}"),
|
||||
followup=[get_text_message("no approval path")],
|
||||
)
|
||||
second = await Runner.run(agent, "call conditional", context={"needs_approval": False})
|
||||
assert not second.interruptions, "run context should be able to skip approval"
|
||||
assert second.final_output == "no approval path"
|
||||
|
||||
assert seen_contexts == [
|
||||
{"needs_approval": True},
|
||||
{"needs_approval": True},
|
||||
{"needs_approval": False},
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Tests for auth and httpx_client_factory params on MCPServerSse and MCPServerStreamableHttp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agents.mcp import MCPServerSse, MCPServerStreamableHttp
|
||||
from agents.mcp.server import _create_default_streamable_http_client
|
||||
|
||||
|
||||
class TestMCPServerSseAuthAndFactory:
|
||||
"""Tests for auth and httpx_client_factory added to MCPServerSseParams."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_default_no_auth_no_factory(self):
|
||||
"""SSE create_streams falls back to the hardened default httpx_client_factory."""
|
||||
with patch("agents.mcp.server.sse_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerSse(params={"url": "http://localhost:8000/sse"})
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/sse",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
httpx_client_factory=_create_default_streamable_http_client,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_with_auth(self):
|
||||
"""SSE create_streams forwards auth and still applies the hardened default factory."""
|
||||
auth = httpx.BasicAuth(username="user", password="pass")
|
||||
with patch("agents.mcp.server.sse_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerSse(params={"url": "http://localhost:8000/sse", "auth": auth})
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/sse",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
auth=auth,
|
||||
httpx_client_factory=_create_default_streamable_http_client,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_with_httpx_client_factory(self):
|
||||
"""SSE create_streams forwards a custom httpx_client_factory when provided."""
|
||||
|
||||
def custom_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(verify=False) # pragma: no cover
|
||||
|
||||
with patch("agents.mcp.server.sse_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerSse(
|
||||
params={
|
||||
"url": "http://localhost:8000/sse",
|
||||
"httpx_client_factory": custom_factory,
|
||||
}
|
||||
)
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/sse",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
httpx_client_factory=custom_factory,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_with_auth_and_factory(self):
|
||||
"""SSE create_streams forwards both auth and httpx_client_factory together."""
|
||||
auth = httpx.BasicAuth(username="user", password="pass")
|
||||
|
||||
def custom_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(verify=False) # pragma: no cover
|
||||
|
||||
with patch("agents.mcp.server.sse_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerSse(
|
||||
params={
|
||||
"url": "http://localhost:8000/sse",
|
||||
"headers": {"X-Token": "abc"},
|
||||
"auth": auth,
|
||||
"httpx_client_factory": custom_factory,
|
||||
}
|
||||
)
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/sse",
|
||||
headers={"X-Token": "abc"},
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
auth=auth,
|
||||
httpx_client_factory=custom_factory,
|
||||
)
|
||||
|
||||
|
||||
class TestMCPServerStreamableHttpAuth:
|
||||
"""Tests for the auth parameter added to MCPServerStreamableHttpParams."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_default_no_auth(self):
|
||||
"""StreamableHttp create_streams omits auth when not provided."""
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:8000/mcp"})
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
terminate_on_close=True,
|
||||
httpx_client_factory=_create_default_streamable_http_client,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_with_auth(self):
|
||||
"""StreamableHttp create_streams forwards the auth parameter when provided."""
|
||||
auth = httpx.BasicAuth(username="user", password="pass")
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerStreamableHttp(
|
||||
params={"url": "http://localhost:8000/mcp", "auth": auth}
|
||||
)
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
terminate_on_close=True,
|
||||
httpx_client_factory=_create_default_streamable_http_client,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_with_auth_and_factory(self):
|
||||
"""StreamableHttp create_streams forwards both auth and httpx_client_factory."""
|
||||
auth = httpx.BasicAuth(username="user", password="pass")
|
||||
|
||||
def custom_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(verify=False) # pragma: no cover
|
||||
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"auth": auth,
|
||||
"httpx_client_factory": custom_factory,
|
||||
}
|
||||
)
|
||||
server.create_streams()
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
terminate_on_close=True,
|
||||
auth=auth,
|
||||
httpx_client_factory=custom_factory,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
_SERVER_EXPORTS = (
|
||||
"LocalMCPApprovalCallable",
|
||||
"MCPServer",
|
||||
"MCPServerSse",
|
||||
"MCPServerSseParams",
|
||||
"MCPServerStdio",
|
||||
"MCPServerStdioParams",
|
||||
"MCPServerStreamableHttp",
|
||||
"MCPServerStreamableHttpParams",
|
||||
)
|
||||
|
||||
|
||||
class _BrokenMCPServerImportFinder(importlib.abc.MetaPathFinder):
|
||||
def find_spec(
|
||||
self,
|
||||
fullname: str,
|
||||
path: object | None,
|
||||
target: ModuleType | None = None,
|
||||
) -> None:
|
||||
if fullname == "agents.mcp.server":
|
||||
raise ImportError("simulated dependency import failure")
|
||||
return None
|
||||
|
||||
|
||||
def _clear_mcp_server_imports(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mcp_module: ModuleType,
|
||||
) -> None:
|
||||
monkeypatch.delitem(sys.modules, "agents.mcp.server", raising=False)
|
||||
monkeypatch.delitem(mcp_module.__dict__, "server", raising=False)
|
||||
for name in _SERVER_EXPORTS:
|
||||
monkeypatch.delitem(mcp_module.__dict__, name, raising=False)
|
||||
|
||||
|
||||
def test_mcp_package_import_does_not_eagerly_import_server(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import agents.mcp as mcp_module
|
||||
|
||||
_clear_mcp_server_imports(monkeypatch, mcp_module)
|
||||
finder = _BrokenMCPServerImportFinder()
|
||||
monkeypatch.setattr(sys, "meta_path", [finder, *sys.meta_path])
|
||||
|
||||
reloaded_mcp = importlib.reload(mcp_module)
|
||||
|
||||
assert reloaded_mcp.MCPUtil is not None
|
||||
|
||||
|
||||
def test_mcp_server_reexport_preserves_underlying_import_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import agents.mcp as mcp_module
|
||||
|
||||
_clear_mcp_server_imports(monkeypatch, mcp_module)
|
||||
finder = _BrokenMCPServerImportFinder()
|
||||
monkeypatch.setattr(sys, "meta_path", [finder, *sys.meta_path])
|
||||
namespace: dict[str, object] = {}
|
||||
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
exec("from agents.mcp import MCPServerStreamableHttp", namespace)
|
||||
|
||||
assert "Failed to import MCPServerStreamableHttp from agents.mcp" in str(exc_info.value)
|
||||
assert isinstance(exc_info.value.__cause__, ImportError)
|
||||
assert "simulated dependency import failure" in str(exc_info.value.__cause__)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for MCP server list_resources, list_resource_templates, and read_resource."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from mcp.types import (
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
TextResourceContents,
|
||||
)
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from agents.mcp import MCPServerStreamableHttp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
return MCPServerStreamableHttp(params={"url": "http://localhost:8000/mcp"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_resources_raises_when_not_connected(server: MCPServerStreamableHttp):
|
||||
"""list_resources raises UserError when server has not been connected."""
|
||||
from agents.exceptions import UserError
|
||||
|
||||
with pytest.raises(UserError, match="Server not initialized"):
|
||||
await server.list_resources()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_resource_templates_raises_when_not_connected(server: MCPServerStreamableHttp):
|
||||
"""list_resource_templates raises UserError when server has not been connected."""
|
||||
from agents.exceptions import UserError
|
||||
|
||||
with pytest.raises(UserError, match="Server not initialized"):
|
||||
await server.list_resource_templates()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_raises_when_not_connected(server: MCPServerStreamableHttp):
|
||||
"""read_resource raises UserError when server has not been connected."""
|
||||
from agents.exceptions import UserError
|
||||
|
||||
with pytest.raises(UserError, match="Server not initialized"):
|
||||
await server.read_resource("file:///etc/hosts")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_resources_returns_result(server: MCPServerStreamableHttp):
|
||||
"""list_resources delegates to the underlying MCP session."""
|
||||
mock_session = MagicMock()
|
||||
expected = ListResourcesResult(
|
||||
resources=[
|
||||
Resource(uri=AnyUrl("file:///readme.md"), name="readme.md", mimeType="text/markdown"),
|
||||
]
|
||||
)
|
||||
mock_session.list_resources = AsyncMock(return_value=expected)
|
||||
server.session = mock_session
|
||||
|
||||
result = await server.list_resources()
|
||||
|
||||
assert result is expected
|
||||
mock_session.list_resources.assert_awaited_once_with(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_resources_forwards_cursor(server: MCPServerStreamableHttp):
|
||||
"""list_resources forwards the cursor argument for pagination."""
|
||||
mock_session = MagicMock()
|
||||
page2 = ListResourcesResult(resources=[])
|
||||
mock_session.list_resources = AsyncMock(return_value=page2)
|
||||
server.session = mock_session
|
||||
|
||||
result = await server.list_resources(cursor="tok_abc")
|
||||
|
||||
assert result is page2
|
||||
mock_session.list_resources.assert_awaited_once_with("tok_abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_resource_templates_returns_result(server: MCPServerStreamableHttp):
|
||||
"""list_resource_templates delegates to the underlying MCP session."""
|
||||
mock_session = MagicMock()
|
||||
expected = ListResourceTemplatesResult(
|
||||
resourceTemplates=[
|
||||
ResourceTemplate(uriTemplate="file:///{path}", name="file"),
|
||||
]
|
||||
)
|
||||
mock_session.list_resource_templates = AsyncMock(return_value=expected)
|
||||
server.session = mock_session
|
||||
|
||||
result = await server.list_resource_templates()
|
||||
|
||||
assert result is expected
|
||||
mock_session.list_resource_templates.assert_awaited_once_with(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_resource_templates_forwards_cursor(server: MCPServerStreamableHttp):
|
||||
"""list_resource_templates forwards the cursor argument for pagination."""
|
||||
mock_session = MagicMock()
|
||||
page2 = ListResourceTemplatesResult(resourceTemplates=[])
|
||||
mock_session.list_resource_templates = AsyncMock(return_value=page2)
|
||||
server.session = mock_session
|
||||
|
||||
result = await server.list_resource_templates(cursor="tok_xyz")
|
||||
|
||||
assert result is page2
|
||||
mock_session.list_resource_templates.assert_awaited_once_with("tok_xyz")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_returns_result(server: MCPServerStreamableHttp):
|
||||
"""read_resource delegates to the underlying MCP session with the given URI."""
|
||||
mock_session = MagicMock()
|
||||
uri = "file:///readme.md"
|
||||
expected = ReadResourceResult(
|
||||
contents=[
|
||||
TextResourceContents(uri=AnyUrl(uri), text="# Hello", mimeType="text/markdown"),
|
||||
]
|
||||
)
|
||||
mock_session.read_resource = AsyncMock(return_value=expected)
|
||||
server.session = mock_session
|
||||
|
||||
result = await server.read_resource(uri)
|
||||
|
||||
assert result is expected
|
||||
mock_session.read_resource.assert_awaited_once_with(AnyUrl(uri))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_methods_raise_not_implemented():
|
||||
"""Bare MCPServer subclasses that don't override resource methods get NotImplementedError."""
|
||||
from mcp.types import CallToolResult, GetPromptResult, ListPromptsResult
|
||||
|
||||
from agents.mcp import MCPServer
|
||||
|
||||
class MinimalServer(MCPServer):
|
||||
"""Minimal subclass implementing only the truly abstract methods."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "minimal"
|
||||
|
||||
async def connect(self) -> None:
|
||||
pass
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
async def list_tools(self, run_context=None, agent=None):
|
||||
return []
|
||||
|
||||
async def call_tool(self, tool_name, tool_arguments, run_context=None, agent=None):
|
||||
return CallToolResult(content=[])
|
||||
|
||||
async def list_prompts(self):
|
||||
return ListPromptsResult(prompts=[])
|
||||
|
||||
async def get_prompt(self, name, arguments=None):
|
||||
return GetPromptResult(messages=[])
|
||||
|
||||
s = MinimalServer()
|
||||
|
||||
with pytest.raises(NotImplementedError, match="list_resources"):
|
||||
await s.list_resources()
|
||||
|
||||
with pytest.raises(NotImplementedError, match="list_resource_templates"):
|
||||
await s.list_resource_templates()
|
||||
|
||||
with pytest.raises(NotImplementedError, match="read_resource"):
|
||||
await s.read_resource("file:///test.txt")
|
||||
@@ -0,0 +1,552 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
GetPromptResult,
|
||||
ListPromptsResult,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
ReadResourceResult,
|
||||
Tool as MCPTool,
|
||||
)
|
||||
|
||||
from agents.mcp import MCPServer, MCPServerManager
|
||||
from agents.run_context import RunContextWrapper
|
||||
|
||||
|
||||
class TaskBoundServer(MCPServer):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._connect_task: asyncio.Task[object] | None = None
|
||||
self.cleaned = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "task-bound"
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._connect_task = asyncio.current_task()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
if self._connect_task is None:
|
||||
raise RuntimeError("Server was not connected")
|
||||
if asyncio.current_task() is not self._connect_task:
|
||||
raise RuntimeError("Attempted to exit cancel scope in a different task")
|
||||
self.cleaned = True
|
||||
|
||||
async def list_tools(
|
||||
self, run_context: RunContextWrapper[Any] | None = None, agent: Any | None = None
|
||||
) -> list[MCPTool]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_prompts(self) -> ListPromptsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=[])
|
||||
|
||||
async def list_resource_templates(
|
||||
self, cursor: str | None = None
|
||||
) -> ListResourceTemplatesResult:
|
||||
return ListResourceTemplatesResult(resourceTemplates=[])
|
||||
|
||||
async def read_resource(self, uri: str) -> ReadResourceResult:
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
|
||||
class FlakyServer(MCPServer):
|
||||
def __init__(self, failures: int) -> None:
|
||||
super().__init__()
|
||||
self.failures_remaining = failures
|
||||
self.connect_calls = 0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "flaky"
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.connect_calls += 1
|
||||
if self.failures_remaining > 0:
|
||||
self.failures_remaining -= 1
|
||||
raise RuntimeError("connect failed")
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
return None
|
||||
|
||||
async def list_tools(
|
||||
self, run_context: RunContextWrapper[Any] | None = None, agent: Any | None = None
|
||||
) -> list[MCPTool]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_prompts(self) -> ListPromptsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=[])
|
||||
|
||||
async def list_resource_templates(
|
||||
self, cursor: str | None = None
|
||||
) -> ListResourceTemplatesResult:
|
||||
return ListResourceTemplatesResult(resourceTemplates=[])
|
||||
|
||||
async def read_resource(self, uri: str) -> ReadResourceResult:
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
|
||||
class CleanupAwareServer(MCPServer):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.connect_calls = 0
|
||||
self.cleanup_calls = 0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cleanup-aware"
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.connect_calls > self.cleanup_calls:
|
||||
raise RuntimeError("connect called without cleanup")
|
||||
self.connect_calls += 1
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
self.cleanup_calls += 1
|
||||
|
||||
async def list_tools(
|
||||
self, run_context: RunContextWrapper[Any] | None = None, agent: Any | None = None
|
||||
) -> list[MCPTool]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_prompts(self) -> ListPromptsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=[])
|
||||
|
||||
async def list_resource_templates(
|
||||
self, cursor: str | None = None
|
||||
) -> ListResourceTemplatesResult:
|
||||
return ListResourceTemplatesResult(resourceTemplates=[])
|
||||
|
||||
async def read_resource(self, uri: str) -> ReadResourceResult:
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
|
||||
class CancelledServer(MCPServer):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cancelled"
|
||||
|
||||
async def connect(self) -> None:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
return None
|
||||
|
||||
async def list_tools(
|
||||
self, run_context: RunContextWrapper[Any] | None = None, agent: Any | None = None
|
||||
) -> list[MCPTool]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_prompts(self) -> ListPromptsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=[])
|
||||
|
||||
async def list_resource_templates(
|
||||
self, cursor: str | None = None
|
||||
) -> ListResourceTemplatesResult:
|
||||
return ListResourceTemplatesResult(resourceTemplates=[])
|
||||
|
||||
async def read_resource(self, uri: str) -> ReadResourceResult:
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
|
||||
class FailingTaskBoundServer(TaskBoundServer):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "failing-task-bound"
|
||||
|
||||
async def connect(self) -> None:
|
||||
await super().connect()
|
||||
raise RuntimeError("connect failed")
|
||||
|
||||
|
||||
class FatalError(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
class FatalTaskBoundServer(TaskBoundServer):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fatal-task-bound"
|
||||
|
||||
async def connect(self) -> None:
|
||||
await super().connect()
|
||||
raise FatalError("fatal connect failed")
|
||||
|
||||
|
||||
class CleanupFailingServer(TaskBoundServer):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cleanup-failing"
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
await super().cleanup()
|
||||
raise RuntimeError("cleanup failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_keeps_connect_and_cleanup_in_same_task() -> None:
|
||||
server = TaskBoundServer()
|
||||
|
||||
async with MCPServerManager([server]) as manager:
|
||||
assert manager.active_servers == [server]
|
||||
|
||||
assert server.cleaned is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_connects_in_worker_tasks_when_parallel() -> None:
|
||||
server = TaskBoundServer()
|
||||
|
||||
async with MCPServerManager([server], connect_in_parallel=True) as manager:
|
||||
assert manager.active_servers == [server]
|
||||
assert server._connect_task is not None
|
||||
assert server._connect_task is not asyncio.current_task()
|
||||
|
||||
assert server.cleaned is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_task_cleanup_raises_without_manager() -> None:
|
||||
server = TaskBoundServer()
|
||||
|
||||
connect_task = asyncio.create_task(server.connect())
|
||||
await connect_task
|
||||
|
||||
with pytest.raises(RuntimeError, match="cancel scope"):
|
||||
await server.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_reconnect_failed_only() -> None:
|
||||
server = FlakyServer(failures=1)
|
||||
|
||||
async with MCPServerManager([server]) as manager:
|
||||
assert manager.active_servers == []
|
||||
assert manager.failed_servers == [server]
|
||||
|
||||
await manager.reconnect()
|
||||
assert manager.active_servers == [server]
|
||||
assert manager.failed_servers == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_reconnect_deduplicates_failures() -> None:
|
||||
server = FlakyServer(failures=2)
|
||||
|
||||
async with MCPServerManager([server], connect_in_parallel=True) as manager:
|
||||
assert manager.active_servers == []
|
||||
assert manager.failed_servers == [server]
|
||||
assert server.connect_calls == 1
|
||||
|
||||
await manager.reconnect()
|
||||
assert manager.active_servers == []
|
||||
assert manager.failed_servers == [server]
|
||||
assert server.connect_calls == 2
|
||||
|
||||
await manager.reconnect()
|
||||
assert manager.active_servers == [server]
|
||||
assert manager.failed_servers == []
|
||||
assert server.connect_calls == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_connect_all_retries_all_servers() -> None:
|
||||
server = FlakyServer(failures=1)
|
||||
manager = MCPServerManager([server])
|
||||
try:
|
||||
await manager.connect_all()
|
||||
assert manager.active_servers == []
|
||||
assert manager.failed_servers == [server]
|
||||
assert server.connect_calls == 1
|
||||
|
||||
await manager.connect_all()
|
||||
assert manager.active_servers == [server]
|
||||
assert manager.failed_servers == []
|
||||
assert server.connect_calls == 2
|
||||
finally:
|
||||
await manager.cleanup_all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_connect_all_is_idempotent() -> None:
|
||||
server = CleanupAwareServer()
|
||||
|
||||
async with MCPServerManager([server]) as manager:
|
||||
assert server.connect_calls == 1
|
||||
await manager.connect_all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_reconnect_all_avoids_duplicate_connections() -> None:
|
||||
server = CleanupAwareServer()
|
||||
|
||||
async with MCPServerManager([server]) as manager:
|
||||
assert server.connect_calls == 1
|
||||
await manager.reconnect(failed_only=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_strict_reconnect_refreshes_active_servers() -> None:
|
||||
server_a = FlakyServer(failures=1)
|
||||
server_b = FlakyServer(failures=2)
|
||||
|
||||
async with MCPServerManager([server_a, server_b]) as manager:
|
||||
assert manager.active_servers == []
|
||||
|
||||
manager.strict = True
|
||||
with pytest.raises(RuntimeError, match="connect failed"):
|
||||
await manager.reconnect()
|
||||
|
||||
assert manager.active_servers == [server_a]
|
||||
assert manager.failed_servers == [server_b]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_strict_connect_preserves_existing_active_servers() -> None:
|
||||
connected_server = TaskBoundServer()
|
||||
failing_server = FlakyServer(failures=2)
|
||||
manager = MCPServerManager([connected_server, failing_server])
|
||||
try:
|
||||
await manager.connect_all()
|
||||
assert manager.active_servers == [connected_server]
|
||||
assert manager.failed_servers == [failing_server]
|
||||
|
||||
manager.strict = True
|
||||
with pytest.raises(RuntimeError, match="connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert manager.active_servers == [connected_server]
|
||||
assert manager.failed_servers == [failing_server]
|
||||
finally:
|
||||
await manager.cleanup_all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_strict_connect_cleans_up_connected_servers() -> None:
|
||||
connected_server = TaskBoundServer()
|
||||
failing_server = FlakyServer(failures=1)
|
||||
manager = MCPServerManager([connected_server, failing_server], strict=True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert connected_server.cleaned is True
|
||||
assert manager.active_servers == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_strict_connect_cleans_up_failed_server() -> None:
|
||||
failing_server = FailingTaskBoundServer()
|
||||
manager = MCPServerManager([failing_server], strict=True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert failing_server.cleaned is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_strict_connect_parallel_cleans_up_failed_server() -> None:
|
||||
failing_server = FailingTaskBoundServer()
|
||||
manager = MCPServerManager([failing_server], strict=True, connect_in_parallel=True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert failing_server.cleaned is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_strict_connect_parallel_cleans_up_workers() -> None:
|
||||
connected_server = TaskBoundServer()
|
||||
failing_server = FailingTaskBoundServer()
|
||||
manager = MCPServerManager(
|
||||
[connected_server, failing_server], strict=True, connect_in_parallel=True
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert connected_server.cleaned is True
|
||||
assert failing_server.cleaned is True
|
||||
assert manager._workers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_parallel_cleanup_clears_worker_on_failure() -> None:
|
||||
server = CleanupFailingServer()
|
||||
manager = MCPServerManager([server], connect_in_parallel=True)
|
||||
await manager.connect_all()
|
||||
await manager.cleanup_all()
|
||||
|
||||
assert server not in manager._workers
|
||||
assert server not in manager._connected_servers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_parallel_cleanup_drops_worker_after_error() -> None:
|
||||
class HangingCleanupWorker:
|
||||
def __init__(self) -> None:
|
||||
self.cleanup_calls = 0
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return False
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
self.cleanup_calls += 1
|
||||
raise RuntimeError("cleanup failed")
|
||||
|
||||
server = FlakyServer(failures=0)
|
||||
manager = MCPServerManager([server], connect_in_parallel=True)
|
||||
manager._workers[server] = cast(Any, HangingCleanupWorker())
|
||||
|
||||
await manager.cleanup_all()
|
||||
|
||||
assert manager._workers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_parallel_suppresses_cancelled_error_in_strict_mode() -> None:
|
||||
server = CancelledServer()
|
||||
manager = MCPServerManager([server], connect_in_parallel=True, strict=True)
|
||||
try:
|
||||
await manager.connect_all()
|
||||
assert manager.active_servers == []
|
||||
assert manager.failed_servers == [server]
|
||||
finally:
|
||||
await manager.cleanup_all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_parallel_propagates_cancelled_error_when_unsuppressed() -> None:
|
||||
server = CancelledServer()
|
||||
manager = MCPServerManager([server], connect_in_parallel=True, suppress_cancelled_error=False)
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await manager.connect_all()
|
||||
finally:
|
||||
await manager.cleanup_all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_sequential_propagates_base_exception() -> None:
|
||||
server = FatalTaskBoundServer()
|
||||
manager = MCPServerManager([server])
|
||||
|
||||
with pytest.raises(FatalError, match="fatal connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert server.cleaned is True
|
||||
assert manager.failed_servers == [server]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_parallel_propagates_base_exception() -> None:
|
||||
server = FatalTaskBoundServer()
|
||||
manager = MCPServerManager([server], connect_in_parallel=True)
|
||||
|
||||
with pytest.raises(FatalError, match="fatal connect failed"):
|
||||
await manager.connect_all()
|
||||
|
||||
assert server.cleaned is True
|
||||
assert manager._workers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_parallel_prefers_cancelled_error_when_unsuppressed() -> None:
|
||||
cancelled_server = CancelledServer()
|
||||
fatal_server = FatalTaskBoundServer()
|
||||
manager = MCPServerManager(
|
||||
[fatal_server, cancelled_server],
|
||||
connect_in_parallel=True,
|
||||
suppress_cancelled_error=False,
|
||||
)
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await manager.connect_all()
|
||||
finally:
|
||||
await manager.cleanup_all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_cleanup_runs_on_cancelled_error_during_connect() -> None:
|
||||
server = CleanupAwareServer()
|
||||
cancelled_server = CancelledServer()
|
||||
manager = MCPServerManager(
|
||||
[server, cancelled_server],
|
||||
suppress_cancelled_error=False,
|
||||
)
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await manager.connect_all()
|
||||
assert server.cleanup_calls == 1
|
||||
finally:
|
||||
await manager.cleanup_all()
|
||||
@@ -0,0 +1,274 @@
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from agents import Agent, RunConfig, Runner
|
||||
|
||||
from ..fake_model import FakeModel
|
||||
from ..test_responses import get_function_tool, get_function_tool_call, get_text_message
|
||||
from ..testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans
|
||||
from .helpers import FakeMCPServer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tracing():
|
||||
model = FakeModel()
|
||||
server = FakeMCPServer()
|
||||
server.add_tool("test_tool_1", {})
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server],
|
||||
tools=[get_function_tool("non_mcp_tool", "tool_result")],
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_1", "")],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
# First run: should list MCP tools before first and second steps
|
||||
x = Runner.run_streamed(agent, input="first_test")
|
||||
async for _ in x.stream_events():
|
||||
pass
|
||||
|
||||
assert x.final_output == "done"
|
||||
spans = fetch_normalized_spans()
|
||||
|
||||
# Should have a single tool listing, and the function span should have MCP data
|
||||
assert spans == snapshot(
|
||||
[
|
||||
{
|
||||
"workflow_name": "Agent workflow",
|
||||
"children": [
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {"server": "fake_mcp_server", "result": ["test_tool_1"]},
|
||||
},
|
||||
{
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "test",
|
||||
"handoffs": [],
|
||||
"tools": ["test_tool_1", "non_mcp_tool"],
|
||||
"output_type": "str",
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "function",
|
||||
"data": {
|
||||
"name": "test_tool_1",
|
||||
"input": "",
|
||||
"output": "{'type': 'text', 'text': 'result_test_tool_1_{}'}", # noqa: E501
|
||||
"mcp_data": {"server": "fake_mcp_server"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {"server": "fake_mcp_server", "result": ["test_tool_1"]},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
server.add_tool("test_tool_2", {})
|
||||
|
||||
SPAN_PROCESSOR_TESTING.clear()
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[
|
||||
get_text_message("a_message"),
|
||||
get_function_tool_call("non_mcp_tool", ""),
|
||||
get_function_tool_call("test_tool_2", ""),
|
||||
],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
await Runner.run(agent, input="second_test")
|
||||
spans = fetch_normalized_spans()
|
||||
|
||||
# Should have a single tool listing, and the function span should have MCP data, and the non-mcp
|
||||
# tool function span should not have MCP data
|
||||
assert spans == snapshot(
|
||||
[
|
||||
{
|
||||
"workflow_name": "Agent workflow",
|
||||
"children": [
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {
|
||||
"server": "fake_mcp_server",
|
||||
"result": ["test_tool_1", "test_tool_2"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "test",
|
||||
"handoffs": [],
|
||||
"tools": ["test_tool_1", "test_tool_2", "non_mcp_tool"],
|
||||
"output_type": "str",
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "function",
|
||||
"data": {
|
||||
"name": "non_mcp_tool",
|
||||
"input": "",
|
||||
"output": "tool_result",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"data": {
|
||||
"name": "test_tool_2",
|
||||
"input": "",
|
||||
"output": "{'type': 'text', 'text': 'result_test_tool_2_{}'}", # noqa: E501
|
||||
"mcp_data": {"server": "fake_mcp_server"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {
|
||||
"server": "fake_mcp_server",
|
||||
"result": ["test_tool_1", "test_tool_2"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
SPAN_PROCESSOR_TESTING.clear()
|
||||
|
||||
# Add more tools to the server
|
||||
server.add_tool("test_tool_3", {})
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_3", "")],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
await Runner.run(agent, input="third_test")
|
||||
|
||||
spans = fetch_normalized_spans()
|
||||
|
||||
# Should have a single tool listing, and the function span should have MCP data, and the non-mcp
|
||||
# tool function span should not have MCP data
|
||||
assert spans == snapshot(
|
||||
[
|
||||
{
|
||||
"workflow_name": "Agent workflow",
|
||||
"children": [
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {
|
||||
"server": "fake_mcp_server",
|
||||
"result": ["test_tool_1", "test_tool_2", "test_tool_3"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "test",
|
||||
"handoffs": [],
|
||||
"tools": ["test_tool_1", "test_tool_2", "test_tool_3", "non_mcp_tool"],
|
||||
"output_type": "str",
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "function",
|
||||
"data": {
|
||||
"name": "test_tool_3",
|
||||
"input": "",
|
||||
"output": "{'type': 'text', 'text': 'result_test_tool_3_{}'}", # noqa: E501
|
||||
"mcp_data": {"server": "fake_mcp_server"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {
|
||||
"server": "fake_mcp_server",
|
||||
"result": ["test_tool_1", "test_tool_2", "test_tool_3"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tracing_redacts_output_when_sensitive_data_disabled():
|
||||
model = FakeModel()
|
||||
server = FakeMCPServer()
|
||||
server.add_tool("test_tool_1", {})
|
||||
agent = Agent(name="test", model=model, mcp_servers=[server])
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_function_tool_call("test_tool_1", "")],
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
await Runner.run(
|
||||
agent,
|
||||
input="redaction_test",
|
||||
run_config=RunConfig(trace_include_sensitive_data=False),
|
||||
)
|
||||
|
||||
spans = fetch_normalized_spans()
|
||||
assert spans == snapshot(
|
||||
[
|
||||
{
|
||||
"workflow_name": "Agent workflow",
|
||||
"children": [
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {"server": "fake_mcp_server", "result": ["test_tool_1"]},
|
||||
},
|
||||
{
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "test",
|
||||
"handoffs": [],
|
||||
"tools": ["test_tool_1"],
|
||||
"output_type": "str",
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "function",
|
||||
"data": {
|
||||
"name": "test_tool_1",
|
||||
"mcp_data": {"server": "fake_mcp_server"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "mcp_tools",
|
||||
"data": {"server": "fake_mcp_server", "result": ["test_tool_1"]},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Union
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp.client.session import MessageHandlerFnT
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
ClientResult,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
ServerCapabilities,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
)
|
||||
|
||||
from agents.mcp.server import (
|
||||
MCPServerSse,
|
||||
MCPServerStdio,
|
||||
MCPServerStreamableHttp,
|
||||
_MCPServerWithClientSession,
|
||||
)
|
||||
|
||||
HandlerMessage = Union[ # noqa: UP007
|
||||
RequestResponder[ServerRequest, ClientResult], ServerNotification, Exception
|
||||
]
|
||||
|
||||
|
||||
class _StubClientSession:
|
||||
"""Stub ClientSession that records the configured message handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
read_stream,
|
||||
write_stream,
|
||||
read_timeout_seconds,
|
||||
*,
|
||||
message_handler=None,
|
||||
**_: object,
|
||||
) -> None:
|
||||
self.message_handler = message_handler
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def initialize(self) -> InitializeResult:
|
||||
capabilities = ServerCapabilities.model_construct()
|
||||
server_info = Implementation.model_construct(name="stub", version="1.0")
|
||||
return InitializeResult(
|
||||
protocolVersion="2024-11-05",
|
||||
capabilities=capabilities,
|
||||
serverInfo=server_info,
|
||||
)
|
||||
|
||||
|
||||
class _MessageHandlerTestServer(_MCPServerWithClientSession):
|
||||
def __init__(self, handler: MessageHandlerFnT | None):
|
||||
super().__init__(
|
||||
cache_tools_list=False,
|
||||
client_session_timeout_seconds=None,
|
||||
message_handler=handler,
|
||||
)
|
||||
|
||||
def create_streams(self):
|
||||
@contextlib.asynccontextmanager
|
||||
async def _streams():
|
||||
send_stream, recv_stream = anyio.create_memory_object_stream[
|
||||
SessionMessage | Exception
|
||||
](1)
|
||||
try:
|
||||
yield recv_stream, send_stream, None
|
||||
finally:
|
||||
await recv_stream.aclose()
|
||||
await send_stream.aclose()
|
||||
|
||||
return _streams()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "test-server"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_session_receives_message_handler(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _recording_client_session(*args, **kwargs):
|
||||
session = _StubClientSession(*args, **kwargs)
|
||||
captured["message_handler"] = session.message_handler
|
||||
return session
|
||||
|
||||
monkeypatch.setattr("agents.mcp.server.ClientSession", _recording_client_session)
|
||||
|
||||
class _AsyncHandler:
|
||||
async def __call__(self, message: HandlerMessage) -> None:
|
||||
del message
|
||||
|
||||
handler: MessageHandlerFnT = _AsyncHandler()
|
||||
|
||||
server = _MessageHandlerTestServer(handler)
|
||||
|
||||
try:
|
||||
await server.connect()
|
||||
finally:
|
||||
await server.cleanup()
|
||||
|
||||
assert captured["message_handler"] is handler
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"server_cls, params",
|
||||
[
|
||||
(MCPServerSse, {"url": "https://example.com"}),
|
||||
(MCPServerStreamableHttp, {"url": "https://example.com"}),
|
||||
(MCPServerStdio, {"command": "python"}),
|
||||
],
|
||||
)
|
||||
def test_message_handler_propagates_to_server_base(server_cls, params):
|
||||
class _AsyncHandler:
|
||||
async def __call__(self, message: HandlerMessage) -> None:
|
||||
del message
|
||||
|
||||
handler: MessageHandlerFnT = _AsyncHandler()
|
||||
|
||||
server = server_cls(params, message_handler=handler)
|
||||
|
||||
assert server.message_handler is handler
|
||||
@@ -0,0 +1,324 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp.types import ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult
|
||||
|
||||
from agents import Agent, Runner
|
||||
from agents.mcp import MCPServer, MCPToolMetaResolver
|
||||
|
||||
from ..fake_model import FakeModel
|
||||
from ..test_responses import get_text_message
|
||||
|
||||
|
||||
class FakeMCPPromptServer(MCPServer):
|
||||
"""Fake MCP server for testing prompt functionality"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_name: str = "fake_prompt_server",
|
||||
tool_meta_resolver: MCPToolMetaResolver | None = None,
|
||||
):
|
||||
super().__init__(tool_meta_resolver=tool_meta_resolver)
|
||||
self.prompts: list[Any] = []
|
||||
self.prompt_results: dict[str, str] = {}
|
||||
self._server_name = server_name
|
||||
|
||||
def add_prompt(self, name: str, description: str, arguments: dict[str, Any] | None = None):
|
||||
"""Add a prompt to the fake server"""
|
||||
from mcp.types import Prompt
|
||||
|
||||
prompt = Prompt(name=name, description=description, arguments=[])
|
||||
self.prompts.append(prompt)
|
||||
|
||||
def set_prompt_result(self, name: str, result: str):
|
||||
"""Set the result that should be returned for a prompt"""
|
||||
self.prompt_results[name] = result
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def cleanup(self):
|
||||
pass
|
||||
|
||||
async def list_prompts(self, run_context=None, agent=None):
|
||||
"""List available prompts"""
|
||||
from mcp.types import ListPromptsResult
|
||||
|
||||
return ListPromptsResult(prompts=self.prompts)
|
||||
|
||||
async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None):
|
||||
"""Get a prompt with arguments"""
|
||||
from mcp.types import GetPromptResult, PromptMessage, TextContent
|
||||
|
||||
if name not in self.prompt_results:
|
||||
raise ValueError(f"Prompt '{name}' not found")
|
||||
|
||||
content = self.prompt_results[name]
|
||||
|
||||
# If it's a format string, try to format it with arguments
|
||||
if arguments and "{" in content:
|
||||
try:
|
||||
content = content.format(**arguments)
|
||||
except KeyError:
|
||||
pass # Use original content if formatting fails
|
||||
|
||||
message = PromptMessage(role="user", content=TextContent(type="text", text=content))
|
||||
|
||||
return GetPromptResult(description=f"Generated prompt for {name}", messages=[message])
|
||||
|
||||
async def list_tools(self, run_context=None, agent=None):
|
||||
return []
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
):
|
||||
raise NotImplementedError("This fake server doesn't support tools")
|
||||
|
||||
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=[])
|
||||
|
||||
async def list_resource_templates(
|
||||
self, cursor: str | None = None
|
||||
) -> ListResourceTemplatesResult:
|
||||
return ListResourceTemplatesResult(resourceTemplates=[])
|
||||
|
||||
async def read_resource(self, uri: str) -> ReadResourceResult:
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._server_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_prompts():
|
||||
"""Test listing available prompts"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt(
|
||||
"generate_code_review_instructions", "Generate agent instructions for code review tasks"
|
||||
)
|
||||
|
||||
result = await server.list_prompts()
|
||||
|
||||
assert len(result.prompts) == 1
|
||||
assert result.prompts[0].name == "generate_code_review_instructions"
|
||||
assert result.prompts[0].description is not None
|
||||
assert "code review" in result.prompts[0].description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_without_arguments():
|
||||
"""Test getting a prompt without arguments"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt("simple_prompt", "A simple prompt")
|
||||
server.set_prompt_result("simple_prompt", "You are a helpful assistant.")
|
||||
|
||||
result = await server.get_prompt("simple_prompt")
|
||||
|
||||
assert len(result.messages) == 1
|
||||
assert result.messages[0].content.text == "You are a helpful assistant."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_with_arguments():
|
||||
"""Test getting a prompt with arguments"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt(
|
||||
"generate_code_review_instructions", "Generate agent instructions for code review tasks"
|
||||
)
|
||||
server.set_prompt_result(
|
||||
"generate_code_review_instructions",
|
||||
"You are a senior {language} code review specialist. Focus on {focus}.",
|
||||
)
|
||||
|
||||
result = await server.get_prompt(
|
||||
"generate_code_review_instructions",
|
||||
{"focus": "security vulnerabilities", "language": "python"},
|
||||
)
|
||||
|
||||
assert len(result.messages) == 1
|
||||
expected_text = (
|
||||
"You are a senior python code review specialist. Focus on security vulnerabilities."
|
||||
)
|
||||
assert result.messages[0].content.text == expected_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_not_found():
|
||||
"""Test getting a prompt that doesn't exist"""
|
||||
server = FakeMCPPromptServer()
|
||||
|
||||
with pytest.raises(ValueError, match="Prompt 'nonexistent' not found"):
|
||||
await server.get_prompt("nonexistent")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_with_prompt_instructions():
|
||||
"""Test using prompt-generated instructions with an agent"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt(
|
||||
"generate_code_review_instructions", "Generate agent instructions for code review tasks"
|
||||
)
|
||||
server.set_prompt_result(
|
||||
"generate_code_review_instructions",
|
||||
"You are a code reviewer. Analyze the provided code for security issues.",
|
||||
)
|
||||
|
||||
# Get instructions from prompt
|
||||
prompt_result = await server.get_prompt("generate_code_review_instructions")
|
||||
instructions = prompt_result.messages[0].content.text
|
||||
|
||||
# Create agent with prompt-generated instructions
|
||||
model = FakeModel()
|
||||
agent = Agent(name="prompt_agent", instructions=instructions, model=model, mcp_servers=[server])
|
||||
|
||||
# Mock model response
|
||||
model.add_multiple_turn_outputs(
|
||||
[[get_text_message("Code analysis complete. Found security vulnerability.")]]
|
||||
)
|
||||
|
||||
# Run the agent
|
||||
result = await Runner.run(agent, input="Review this code: def unsafe_exec(cmd): os.system(cmd)")
|
||||
|
||||
assert "Code analysis complete" in result.final_output
|
||||
assert (
|
||||
agent.instructions
|
||||
== "You are a code reviewer. Analyze the provided code for security issues."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_agent_with_prompt_instructions_streaming(streaming: bool):
|
||||
"""Test using prompt-generated instructions with streaming and non-streaming"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt(
|
||||
"generate_code_review_instructions", "Generate agent instructions for code review tasks"
|
||||
)
|
||||
server.set_prompt_result(
|
||||
"generate_code_review_instructions",
|
||||
"You are a {language} code reviewer focusing on {focus}.",
|
||||
)
|
||||
|
||||
# Get instructions from prompt with arguments
|
||||
prompt_result = await server.get_prompt(
|
||||
"generate_code_review_instructions", {"language": "Python", "focus": "security"}
|
||||
)
|
||||
instructions = prompt_result.messages[0].content.text
|
||||
|
||||
# Create agent
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="streaming_prompt_agent", instructions=instructions, model=model, mcp_servers=[server]
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs([[get_text_message("Security analysis complete.")]])
|
||||
|
||||
if streaming:
|
||||
streaming_result = Runner.run_streamed(agent, input="Review code")
|
||||
async for _ in streaming_result.stream_events():
|
||||
pass
|
||||
final_result = streaming_result.final_output
|
||||
else:
|
||||
result = await Runner.run(agent, input="Review code")
|
||||
final_result = result.final_output
|
||||
|
||||
assert "Security analysis complete" in final_result
|
||||
assert agent.instructions == "You are a Python code reviewer focusing on security."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_prompts():
|
||||
"""Test server with multiple prompts"""
|
||||
server = FakeMCPPromptServer()
|
||||
|
||||
# Add multiple prompts
|
||||
server.add_prompt(
|
||||
"generate_code_review_instructions", "Generate agent instructions for code review tasks"
|
||||
)
|
||||
server.add_prompt(
|
||||
"generate_testing_instructions", "Generate agent instructions for testing tasks"
|
||||
)
|
||||
|
||||
server.set_prompt_result("generate_code_review_instructions", "You are a code reviewer.")
|
||||
server.set_prompt_result("generate_testing_instructions", "You are a test engineer.")
|
||||
|
||||
# Test listing prompts
|
||||
prompts_result = await server.list_prompts()
|
||||
assert len(prompts_result.prompts) == 2
|
||||
|
||||
prompt_names = [p.name for p in prompts_result.prompts]
|
||||
assert "generate_code_review_instructions" in prompt_names
|
||||
assert "generate_testing_instructions" in prompt_names
|
||||
|
||||
# Test getting each prompt
|
||||
review_result = await server.get_prompt("generate_code_review_instructions")
|
||||
assert review_result.messages[0].content.text == "You are a code reviewer."
|
||||
|
||||
testing_result = await server.get_prompt("generate_testing_instructions")
|
||||
assert testing_result.messages[0].content.text == "You are a test engineer."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_with_complex_arguments():
|
||||
"""Test prompt with complex argument formatting"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt(
|
||||
"generate_detailed_instructions", "Generate detailed instructions with multiple parameters"
|
||||
)
|
||||
server.set_prompt_result(
|
||||
"generate_detailed_instructions",
|
||||
"You are a {role} specialist. Your focus is on {focus}. "
|
||||
+ "You work with {language} code. Your experience level is {level}.",
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"role": "security",
|
||||
"focus": "vulnerability detection",
|
||||
"language": "Python",
|
||||
"level": "senior",
|
||||
}
|
||||
|
||||
result = await server.get_prompt("generate_detailed_instructions", arguments)
|
||||
|
||||
expected = (
|
||||
"You are a security specialist. Your focus is on vulnerability detection. "
|
||||
"You work with Python code. Your experience level is senior."
|
||||
)
|
||||
assert result.messages[0].content.text == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_with_missing_arguments():
|
||||
"""Test prompt with missing arguments in format string"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt("incomplete_prompt", "Prompt with missing arguments")
|
||||
server.set_prompt_result("incomplete_prompt", "You are a {role} working on {task}.")
|
||||
|
||||
# Only provide one of the required arguments
|
||||
result = await server.get_prompt("incomplete_prompt", {"role": "developer"})
|
||||
|
||||
# Should return the original string since formatting fails
|
||||
assert result.messages[0].content.text == "You are a {role} working on {task}."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_server_cleanup():
|
||||
"""Test that prompt server cleanup works correctly"""
|
||||
server = FakeMCPPromptServer()
|
||||
server.add_prompt("test_prompt", "Test prompt")
|
||||
server.set_prompt_result("test_prompt", "Test result")
|
||||
|
||||
# Test that server works before cleanup
|
||||
result = await server.get_prompt("test_prompt")
|
||||
assert result.messages[0].content.text == "Test result"
|
||||
|
||||
# Cleanup should not raise any errors
|
||||
await server.cleanup()
|
||||
|
||||
# Server should still work after cleanup (in this fake implementation)
|
||||
result = await server.get_prompt("test_prompt")
|
||||
assert result.messages[0].content.text == "Test result"
|
||||
@@ -0,0 +1,402 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
FunctionTool,
|
||||
ModelBehaviorError,
|
||||
RunContextWrapper,
|
||||
Runner,
|
||||
UserError,
|
||||
default_tool_error_function,
|
||||
handoff,
|
||||
)
|
||||
from agents.exceptions import AgentsException
|
||||
|
||||
from ..fake_model import FakeModel
|
||||
from ..test_responses import get_function_tool_call, get_text_message
|
||||
from .helpers import FakeMCPServer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_calls_mcp_tool(streaming: bool):
|
||||
"""Test that the runner calls an MCP tool when the model produces a tool call."""
|
||||
server = FakeMCPServer()
|
||||
server.add_tool("test_tool_1", {})
|
||||
server.add_tool("test_tool_2", {})
|
||||
server.add_tool("test_tool_3", {})
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server],
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_2", "")],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
assert server.tool_calls == ["test_tool_2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_asserts_when_mcp_tool_not_found(streaming: bool):
|
||||
"""Test that the runner asserts when an MCP tool is not found."""
|
||||
server = FakeMCPServer()
|
||||
server.add_tool("test_tool_1", {})
|
||||
server.add_tool("test_tool_2", {})
|
||||
server.add_tool("test_tool_3", {})
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server],
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_doesnt_exist", "")],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ModelBehaviorError):
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_works_with_multiple_mcp_servers(streaming: bool):
|
||||
"""Test that the runner works with multiple MCP servers."""
|
||||
server1 = FakeMCPServer()
|
||||
server1.add_tool("test_tool_1", {})
|
||||
|
||||
server2 = FakeMCPServer()
|
||||
server2.add_tool("test_tool_2", {})
|
||||
server2.add_tool("test_tool_3", {})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server1, server2],
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_2", "")],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
assert server1.tool_calls == []
|
||||
assert server2.tool_calls == ["test_tool_2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_errors_when_mcp_tools_clash(streaming: bool):
|
||||
"""Test that the runner errors when multiple servers have the same tool name."""
|
||||
server1 = FakeMCPServer()
|
||||
server1.add_tool("test_tool_1", {})
|
||||
server1.add_tool("test_tool_2", {})
|
||||
|
||||
server2 = FakeMCPServer()
|
||||
server2.add_tool("test_tool_2", {})
|
||||
server2.add_tool("test_tool_3", {})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server1, server2],
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_3", "")],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(UserError):
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_can_call_server_prefixed_mcp_tool_names(streaming: bool):
|
||||
server1 = FakeMCPServer(server_name="docs")
|
||||
server1.add_tool("search", {})
|
||||
|
||||
server2 = FakeMCPServer(server_name="calendar")
|
||||
server2.add_tool("search", {})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server1, server2],
|
||||
mcp_config={"include_server_in_tool_names": True},
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("a_message"), get_function_tool_call("mcp_calendar__search", "")],
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
assert server1.tool_calls == []
|
||||
assert server2.tool_calls == ["search"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_agent_tools(streaming: bool):
|
||||
server1 = FakeMCPServer(server_name="docs")
|
||||
server1.add_tool("search", {})
|
||||
|
||||
server2 = FakeMCPServer(server_name="calendar")
|
||||
server2.add_tool("search", {})
|
||||
|
||||
local_tool_calls: list[str] = []
|
||||
|
||||
async def invoke_local_tool(context: Any, input_json: str) -> str:
|
||||
local_tool_calls.append(input_json)
|
||||
return "local"
|
||||
|
||||
local_tool = FunctionTool(
|
||||
name="mcp_calendar__search",
|
||||
description="Local tool that intentionally collides with the natural MCP prefix.",
|
||||
params_json_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
||||
on_invoke_tool=invoke_local_tool,
|
||||
)
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
tools=[local_tool],
|
||||
mcp_servers=[server1, server2],
|
||||
mcp_config={"include_server_in_tool_names": True},
|
||||
)
|
||||
|
||||
mcp_tools = await agent.get_mcp_tools(RunContextWrapper(context=None))
|
||||
calendar_search_tool_name = next(
|
||||
tool.name
|
||||
for tool in mcp_tools
|
||||
if getattr(getattr(tool, "_tool_origin", None), "mcp_server_name", None) == "calendar"
|
||||
)
|
||||
assert calendar_search_tool_name != "mcp_calendar__search"
|
||||
assert calendar_search_tool_name.startswith("mcp_calendar__search_")
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("a_message"), get_function_tool_call(calendar_search_tool_name, "")],
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
assert local_tool_calls == []
|
||||
assert server1.tool_calls == []
|
||||
assert server2.tool_calls == ["search"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_prefixed_mcp_tool_names_do_not_collide_with_handoffs(streaming: bool):
|
||||
server = FakeMCPServer(server_name="calendar")
|
||||
server.add_tool("search", {})
|
||||
|
||||
target_model = FakeModel()
|
||||
target_agent = Agent(name="calendar_agent", model=target_model)
|
||||
target_model.add_multiple_turn_outputs([[get_text_message("handoff target")]])
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
handoffs=[handoff(target_agent, tool_name_override="mcp_calendar__search")],
|
||||
mcp_servers=[server],
|
||||
mcp_config={"include_server_in_tool_names": True},
|
||||
)
|
||||
|
||||
mcp_tools = await agent.get_mcp_tools(RunContextWrapper(context=None))
|
||||
assert len(mcp_tools) == 1
|
||||
calendar_search_tool_name = mcp_tools[0].name
|
||||
assert calendar_search_tool_name != "mcp_calendar__search"
|
||||
assert calendar_search_tool_name.startswith("mcp_calendar__search_")
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("a_message"), get_function_tool_call(calendar_search_tool_name, "")],
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
assert server.tool_calls == ["search"]
|
||||
assert target_model.first_turn_args is None
|
||||
|
||||
|
||||
class Foo(BaseModel):
|
||||
bar: str
|
||||
baz: int
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_calls_mcp_tool_with_args(streaming: bool):
|
||||
"""Test that the runner calls an MCP tool when the model produces a tool call."""
|
||||
server = FakeMCPServer()
|
||||
await server.connect()
|
||||
server.add_tool("test_tool_1", {})
|
||||
server.add_tool("test_tool_2", Foo.model_json_schema())
|
||||
server.add_tool("test_tool_3", {})
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server],
|
||||
)
|
||||
|
||||
json_args = json.dumps(Foo(bar="baz", baz=1).model_dump())
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
# First turn: a message and tool call
|
||||
[get_text_message("a_message"), get_function_tool_call("test_tool_2", json_args)],
|
||||
# Second turn: text message
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
else:
|
||||
await Runner.run(agent, input="user_message")
|
||||
|
||||
assert server.tool_calls == ["test_tool_2"]
|
||||
assert server.tool_results == [f"result_test_tool_2_{json_args}"]
|
||||
|
||||
await server.cleanup()
|
||||
|
||||
|
||||
class CrashingFakeMCPServer(FakeMCPServer):
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, object] | None,
|
||||
meta: dict[str, object] | None = None,
|
||||
):
|
||||
raise Exception("Crash!")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
async def test_runner_emits_mcp_error_tool_call_output_item(streaming: bool):
|
||||
"""Runner should emit tool_call_output_item with failure output when MCP tool raises."""
|
||||
server = CrashingFakeMCPServer()
|
||||
server.add_tool("crashing_tool", {})
|
||||
|
||||
model = FakeModel()
|
||||
agent = Agent(
|
||||
name="test",
|
||||
model=model,
|
||||
mcp_servers=[server],
|
||||
)
|
||||
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[get_text_message("a_message"), get_function_tool_call("crashing_tool", "{}")],
|
||||
[get_text_message("done")],
|
||||
]
|
||||
)
|
||||
|
||||
if streaming:
|
||||
streamed_result = Runner.run_streamed(agent, input="user_message")
|
||||
async for _ in streamed_result.stream_events():
|
||||
pass
|
||||
tool_output_items = [
|
||||
item for item in streamed_result.new_items if item.type == "tool_call_output_item"
|
||||
]
|
||||
assert streamed_result.final_output == "done"
|
||||
else:
|
||||
non_streamed_result = await Runner.run(agent, input="user_message")
|
||||
tool_output_items = [
|
||||
item for item in non_streamed_result.new_items if item.type == "tool_call_output_item"
|
||||
]
|
||||
assert non_streamed_result.final_output == "done"
|
||||
|
||||
assert tool_output_items, "Expected tool_call_output_item for MCP failure"
|
||||
wrapped_error = AgentsException(
|
||||
"Error invoking MCP tool crashing_tool on server 'fake_mcp_server': Crash!"
|
||||
)
|
||||
expected_error_message = default_tool_error_function(
|
||||
RunContextWrapper(context=None),
|
||||
wrapped_error,
|
||||
)
|
||||
assert tool_output_items[0].output == expected_error_message
|
||||
@@ -0,0 +1,85 @@
|
||||
import builtins
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agents import Agent
|
||||
from agents.exceptions import UserError
|
||||
from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession
|
||||
from agents.run_context import RunContextWrapper
|
||||
|
||||
# Handle Python version compatibility for ExceptionGroups
|
||||
if sys.version_info < (3, 11):
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
else:
|
||||
BaseExceptionGroup = builtins.BaseExceptionGroup
|
||||
|
||||
|
||||
class CrashingClientSessionServer(_MCPServerWithClientSession):
|
||||
def __init__(self):
|
||||
super().__init__(cache_tools_list=False, client_session_timeout_seconds=5)
|
||||
self.cleanup_called = False
|
||||
|
||||
def create_streams(self):
|
||||
raise ValueError("Crash!")
|
||||
|
||||
async def cleanup(self):
|
||||
self.cleanup_called = True
|
||||
await super().cleanup()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "crashing_client_session_server"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_errors_cause_error_and_cleanup_called():
|
||||
server = CrashingClientSessionServer()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await server.connect()
|
||||
|
||||
assert server.cleanup_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_calling_connect_causes_error():
|
||||
server = CrashingClientSessionServer()
|
||||
|
||||
run_context = RunContextWrapper(context=None)
|
||||
agent = Agent(name="test_agent", instructions="Test agent")
|
||||
|
||||
with pytest.raises(UserError):
|
||||
await server.list_tools(run_context, agent)
|
||||
|
||||
with pytest.raises(UserError):
|
||||
await server.call_tool("foo", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_nested_exception_group_mapping():
|
||||
"""
|
||||
Regression test ensuring that nested ExceptionGroups containing HTTP errors
|
||||
are recursively extracted and mapped to a UserError in call_tool().
|
||||
"""
|
||||
# 1. Initialize the server with mock streamable parameters
|
||||
server = MCPServerStreamableHttp(params={"url": "http://fake-mcp-server"})
|
||||
|
||||
# 2. Simulate an active connection by mocking the session object
|
||||
server.session = MagicMock()
|
||||
|
||||
# 3. Construct a nested ExceptionGroup hierarchy containing a connection error
|
||||
http_error = httpx.ConnectError("Network unreachable")
|
||||
inner_group = BaseExceptionGroup("inner_failures", [http_error])
|
||||
outer_group = BaseExceptionGroup("outer_failures", [inner_group])
|
||||
|
||||
# 4 & 5. Mock the internal retry handler to raise the nested group, and assert UserError
|
||||
with patch.object(server, "_call_tool_with_isolated_retry", side_effect=outer_group):
|
||||
with pytest.raises(UserError) as exc_info:
|
||||
await server.call_tool(tool_name="test_tool", arguments={})
|
||||
|
||||
# 6. Verify that the user-facing message is mapped correctly based on the root cause
|
||||
assert "Connection lost" in str(exc_info.value)
|
||||
assert exc_info.value.__cause__ is http_error
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Tests for MCPServerStreamableHttp httpx_client_factory functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from anyio import create_memory_object_stream
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest
|
||||
|
||||
from agents.mcp import MCPServerStreamableHttp
|
||||
from agents.mcp.server import (
|
||||
_create_default_streamable_http_client,
|
||||
_InitializedNotificationTolerantStreamableHTTPTransport,
|
||||
_streamablehttp_client_with_transport,
|
||||
)
|
||||
|
||||
|
||||
class TestMCPServerStreamableHttpClientFactory:
|
||||
"""Test cases for custom httpx_client_factory parameter."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_httpx_client_factory(self):
|
||||
"""Test that default behavior works when no custom factory is provided."""
|
||||
# Mock the streamablehttp_client to avoid actual network calls
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"headers": {"Authorization": "Bearer token"},
|
||||
"timeout": 10,
|
||||
}
|
||||
)
|
||||
|
||||
server.create_streams()
|
||||
|
||||
# Verify streamablehttp_client was called with the hardened default factory.
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
timeout=10,
|
||||
sse_read_timeout=300, # Default value
|
||||
terminate_on_close=True, # Default value
|
||||
httpx_client_factory=_create_default_streamable_http_client,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_httpx_client_factory(self):
|
||||
"""Test that custom httpx_client_factory is passed correctly."""
|
||||
|
||||
# Create a custom factory function
|
||||
def custom_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
verify=False, # Disable SSL verification for testing
|
||||
timeout=httpx.Timeout(60.0),
|
||||
headers={"X-Custom-Header": "test"},
|
||||
)
|
||||
|
||||
# Mock the streamablehttp_client to avoid actual network calls
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"headers": {"Authorization": "Bearer token"},
|
||||
"timeout": 10,
|
||||
"httpx_client_factory": custom_factory,
|
||||
}
|
||||
)
|
||||
|
||||
# Create streams should pass the custom factory
|
||||
server.create_streams()
|
||||
|
||||
# Verify streamablehttp_client was called with the custom factory
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
timeout=10,
|
||||
sse_read_timeout=300, # Default value
|
||||
terminate_on_close=True, # Default value
|
||||
httpx_client_factory=custom_factory,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_httpx_client_factory_with_ssl_cert(self):
|
||||
"""Test custom factory with SSL certificate configuration."""
|
||||
|
||||
def ssl_cert_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
verify="/path/to/cert.pem", # Custom SSL certificate
|
||||
timeout=httpx.Timeout(120.0),
|
||||
)
|
||||
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "https://secure-server.com/mcp",
|
||||
"timeout": 30,
|
||||
"httpx_client_factory": ssl_cert_factory,
|
||||
}
|
||||
)
|
||||
|
||||
server.create_streams()
|
||||
|
||||
mock_client.assert_called_once_with(
|
||||
url="https://secure-server.com/mcp",
|
||||
headers=None,
|
||||
timeout=30,
|
||||
sse_read_timeout=300,
|
||||
terminate_on_close=True,
|
||||
httpx_client_factory=ssl_cert_factory,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_httpx_client_factory_with_proxy(self):
|
||||
"""Test custom factory with proxy configuration."""
|
||||
|
||||
def proxy_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
proxy="http://proxy.example.com:8080",
|
||||
timeout=httpx.Timeout(60.0),
|
||||
)
|
||||
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"httpx_client_factory": proxy_factory,
|
||||
}
|
||||
)
|
||||
|
||||
server.create_streams()
|
||||
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers=None,
|
||||
timeout=5, # Default value
|
||||
sse_read_timeout=300,
|
||||
terminate_on_close=True,
|
||||
httpx_client_factory=proxy_factory,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_httpx_client_factory_with_retry_logic(self):
|
||||
"""Test custom factory with retry logic configuration."""
|
||||
|
||||
def retry_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
# Note: httpx doesn't have built-in retry, but this shows how
|
||||
# a custom factory could be used to configure retry behavior
|
||||
# through middleware or other mechanisms
|
||||
)
|
||||
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"httpx_client_factory": retry_factory,
|
||||
}
|
||||
)
|
||||
|
||||
server.create_streams()
|
||||
|
||||
mock_client.assert_called_once_with(
|
||||
url="http://localhost:8000/mcp",
|
||||
headers=None,
|
||||
timeout=5,
|
||||
sse_read_timeout=300,
|
||||
terminate_on_close=True,
|
||||
httpx_client_factory=retry_factory,
|
||||
)
|
||||
|
||||
def test_httpx_client_factory_type_annotation(self):
|
||||
"""Test that the type annotation is correct for httpx_client_factory."""
|
||||
from agents.mcp.server import MCPServerStreamableHttpParams
|
||||
|
||||
# This test ensures the type annotation is properly set
|
||||
# We can't easily test the TypedDict at runtime, but we can verify
|
||||
# that the import works and the type is available
|
||||
assert hasattr(MCPServerStreamableHttpParams, "__annotations__")
|
||||
|
||||
# Verify that the httpx_client_factory parameter is in the annotations
|
||||
annotations = MCPServerStreamableHttpParams.__annotations__
|
||||
assert "httpx_client_factory" in annotations
|
||||
|
||||
# The annotation should contain the string representation of the type
|
||||
annotation_str = str(annotations["httpx_client_factory"])
|
||||
assert "HttpClientFactory" in annotation_str
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_parameters_with_custom_factory(self):
|
||||
"""Test that all parameters work together with custom factory."""
|
||||
|
||||
def comprehensive_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
verify=False,
|
||||
timeout=httpx.Timeout(90.0),
|
||||
headers={"X-Test": "value"},
|
||||
)
|
||||
|
||||
with patch("agents.mcp.server.streamablehttp_client") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": {"Authorization": "Bearer token"},
|
||||
"timeout": 45,
|
||||
"sse_read_timeout": 600,
|
||||
"terminate_on_close": False,
|
||||
"httpx_client_factory": comprehensive_factory,
|
||||
}
|
||||
)
|
||||
|
||||
server.create_streams()
|
||||
|
||||
mock_client.assert_called_once_with(
|
||||
url="https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
timeout=45,
|
||||
sse_read_timeout=600,
|
||||
terminate_on_close=False,
|
||||
httpx_client_factory=comprehensive_factory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialized_notification_failure_returns_synthetic_success():
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503, request=request)
|
||||
|
||||
transport = _InitializedNotificationTolerantStreamableHTTPTransport("https://example.test/mcp")
|
||||
read_stream_writer, _ = create_memory_object_stream[SessionMessage | Exception](0)
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
ctx = MagicMock()
|
||||
ctx.client = client
|
||||
ctx.read_stream_writer = read_stream_writer
|
||||
ctx.session_message = SessionMessage(
|
||||
JSONRPCMessage(
|
||||
JSONRPCNotification(
|
||||
jsonrpc="2.0",
|
||||
method="notifications/initialized",
|
||||
params={},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await transport._handle_post_request(ctx)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await read_stream_writer.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialized_notification_transport_exception_returns_synthetic_success():
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom", request=request)
|
||||
|
||||
transport = _InitializedNotificationTolerantStreamableHTTPTransport("https://example.test/mcp")
|
||||
read_stream_writer, _ = create_memory_object_stream[SessionMessage | Exception](0)
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
ctx = MagicMock()
|
||||
ctx.client = client
|
||||
ctx.read_stream_writer = read_stream_writer
|
||||
ctx.session_message = SessionMessage(
|
||||
JSONRPCMessage(
|
||||
JSONRPCNotification(
|
||||
jsonrpc="2.0",
|
||||
method="notifications/initialized",
|
||||
params={},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await transport._handle_post_request(ctx)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await read_stream_writer.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_server_passes_ignore_initialized_notification_failure():
|
||||
with patch("agents.mcp.server._streamablehttp_client_with_transport") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
|
||||
server = MCPServerStreamableHttp(
|
||||
params={
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"ignore_initialized_notification_failure": True,
|
||||
}
|
||||
)
|
||||
|
||||
server.create_streams()
|
||||
|
||||
kwargs = mock_client.call_args.kwargs
|
||||
assert kwargs["url"] == "http://localhost:8000/mcp"
|
||||
assert kwargs["headers"] is None
|
||||
assert kwargs["timeout"] == 5
|
||||
assert kwargs["sse_read_timeout"] == 300
|
||||
assert kwargs["terminate_on_close"] is True
|
||||
assert kwargs["httpx_client_factory"] is _create_default_streamable_http_client
|
||||
assert (
|
||||
kwargs["transport_factory"] is _InitializedNotificationTolerantStreamableHTTPTransport
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_preserves_non_initialized_failures():
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom", request=request)
|
||||
|
||||
transport = _InitializedNotificationTolerantStreamableHTTPTransport("https://example.test/mcp")
|
||||
read_stream_writer, _ = create_memory_object_stream[SessionMessage | Exception](0)
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
ctx = MagicMock()
|
||||
ctx.client = client
|
||||
ctx.read_stream_writer = read_stream_writer
|
||||
ctx.session_message = SessionMessage(
|
||||
JSONRPCMessage(
|
||||
JSONRPCRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/list",
|
||||
params={},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await transport._handle_post_request(ctx)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await read_stream_writer.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_client_preserves_custom_factory_headers_timeout_and_auth():
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
class RecordingAuth(httpx.Auth):
|
||||
def auth_flow(self, request: httpx.Request):
|
||||
request.headers["Authorization"] = f"Basic {base64.b64encode(b'user:pass').decode()}"
|
||||
yield request
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["request_headers"] = dict(request.headers)
|
||||
return httpx.Response(200, request=request)
|
||||
|
||||
def base_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
seen["factory_headers"] = headers
|
||||
seen["factory_timeout"] = timeout
|
||||
seen["factory_auth"] = auth
|
||||
return httpx.AsyncClient(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
auth=auth,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
timeout = httpx.Timeout(12.0)
|
||||
auth = RecordingAuth()
|
||||
async with _streamablehttp_client_with_transport(
|
||||
"https://example.test/mcp",
|
||||
headers={"X-Test": "value"},
|
||||
timeout=12.0,
|
||||
sse_read_timeout=30.0,
|
||||
httpx_client_factory=base_factory,
|
||||
auth=auth,
|
||||
transport_factory=_InitializedNotificationTolerantStreamableHTTPTransport,
|
||||
):
|
||||
pass
|
||||
|
||||
assert seen["factory_headers"] == {"X-Test": "value"}
|
||||
seen_timeout = seen["factory_timeout"]
|
||||
assert isinstance(seen_timeout, httpx.Timeout)
|
||||
assert seen_timeout.connect == timeout.connect
|
||||
assert seen_timeout.read == 30.0
|
||||
assert seen_timeout.write == timeout.write
|
||||
assert seen_timeout.pool == timeout.pool
|
||||
assert seen["factory_auth"] is auth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_streamable_http_client_matches_expected_defaults():
|
||||
timeout = httpx.Timeout(12.0)
|
||||
auth = httpx.BasicAuth("user", "pass")
|
||||
|
||||
client = _create_default_streamable_http_client(
|
||||
headers={"X-Test": "value"},
|
||||
timeout=timeout,
|
||||
auth=auth,
|
||||
)
|
||||
try:
|
||||
assert client.headers["X-Test"] == "value"
|
||||
assert client.timeout.connect == timeout.connect
|
||||
assert client.timeout.read == timeout.read
|
||||
assert client.timeout.write == timeout.write
|
||||
assert client.timeout.pool == timeout.pool
|
||||
assert client.auth is auth
|
||||
assert client.follow_redirects is False
|
||||
finally:
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for MCPServerStreamableHttp.session_id property (issue #924)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.mcp import MCPServerStreamableHttp
|
||||
|
||||
|
||||
class TestStreamableHttpSessionId:
|
||||
"""Tests that the session_id property is correctly exposed."""
|
||||
|
||||
def test_session_id_is_none_before_connect(self):
|
||||
"""session_id should be None when the server has not been connected yet."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"})
|
||||
assert server.session_id is None
|
||||
|
||||
def test_session_id_returns_none_when_callback_is_none(self):
|
||||
"""session_id should be None when _get_session_id callback is None."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"})
|
||||
server._get_session_id = None
|
||||
assert server.session_id is None
|
||||
|
||||
def test_session_id_returns_callback_value(self):
|
||||
"""session_id should return the value from the get_session_id callback."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"})
|
||||
mock_get_session_id = MagicMock(return_value="test-session-abc123")
|
||||
server._get_session_id = mock_get_session_id
|
||||
assert server.session_id == "test-session-abc123"
|
||||
mock_get_session_id.assert_called_once()
|
||||
|
||||
def test_session_id_returns_none_when_callback_returns_none(self):
|
||||
"""session_id should return None when the callback itself returns None."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"})
|
||||
mock_get_session_id = MagicMock(return_value=None)
|
||||
server._get_session_id = mock_get_session_id
|
||||
assert server.session_id is None
|
||||
|
||||
def test_session_id_reflects_updated_callback_value(self):
|
||||
"""session_id should reflect the latest value from the callback each time."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"})
|
||||
call_count = 0
|
||||
|
||||
def changing_callback() -> str | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"session-{call_count}"
|
||||
|
||||
server._get_session_id = changing_callback
|
||||
assert server.session_id == "session-1"
|
||||
assert server.session_id == "session-2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_captures_get_session_id_callback(self):
|
||||
"""connect() should capture the third element of the transport tuple as _get_session_id."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:9999/mcp"})
|
||||
|
||||
mock_read = AsyncMock()
|
||||
mock_write = AsyncMock()
|
||||
mock_get_session_id = MagicMock(return_value="captured-session-xyz")
|
||||
|
||||
mock_initialize_result = MagicMock()
|
||||
mock_session = AsyncMock()
|
||||
mock_session.initialize = AsyncMock(return_value=mock_initialize_result)
|
||||
|
||||
# Simulate the full 3-tuple that streamablehttp_client returns
|
||||
transport_tuple = (mock_read, mock_write, mock_get_session_id)
|
||||
|
||||
with patch("agents.mcp.server.ClientSession") as mock_client_session_cls:
|
||||
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(
|
||||
server,
|
||||
"create_streams",
|
||||
) as mock_create_streams:
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=transport_tuple)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_create_streams.return_value = mock_cm
|
||||
|
||||
with patch.object(server.exit_stack, "enter_async_context") as mock_enter:
|
||||
# First call returns transport, second call returns session
|
||||
mock_enter.side_effect = [transport_tuple, mock_session]
|
||||
mock_session.initialize.return_value = mock_initialize_result
|
||||
|
||||
await server.connect()
|
||||
|
||||
# After connect, _get_session_id should be the callable from the transport
|
||||
assert server._get_session_id is mock_get_session_id
|
||||
assert server.session_id == "captured-session-xyz"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_id_is_none_after_cleanup():
|
||||
"""session_id must return None after disconnect (cleanup clears _get_session_id)."""
|
||||
server = MCPServerStreamableHttp(params={"url": "http://localhost:8000/mcp"})
|
||||
|
||||
mock_get_session_id = MagicMock(return_value="session-to-clear")
|
||||
# Manually inject a session-id callback to simulate a connected state
|
||||
server._get_session_id = mock_get_session_id
|
||||
server.session = MagicMock() # pretend connected
|
||||
|
||||
assert server.session_id == "session-to-clear"
|
||||
|
||||
# Now simulate cleanup completing (exit_stack.aclose is a no-op here)
|
||||
with patch.object(server.exit_stack, "aclose", new_callable=AsyncMock):
|
||||
await server.cleanup()
|
||||
|
||||
# After cleanup both session and _get_session_id must be None
|
||||
assert server.session is None
|
||||
assert server._get_session_id is None
|
||||
assert server.session_id is None
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Tool filtering tests use FakeMCPServer instead of real MCPServer implementations to avoid
|
||||
external dependencies (processes, network connections) and ensure fast, reliable unit tests.
|
||||
FakeMCPServer delegates filtering logic to the real _MCPServerWithClientSession implementation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from mcp import Tool as MCPTool
|
||||
|
||||
from agents import Agent
|
||||
from agents.mcp import ToolFilterContext, create_static_tool_filter
|
||||
from agents.run_context import RunContextWrapper
|
||||
|
||||
from .helpers import FakeMCPServer
|
||||
|
||||
|
||||
def create_test_agent(name: str = "test_agent") -> Agent:
|
||||
"""Create a test agent for filtering tests."""
|
||||
return Agent(name=name, instructions="Test agent")
|
||||
|
||||
|
||||
def create_test_context() -> RunContextWrapper:
|
||||
"""Create a test run context for filtering tests."""
|
||||
return RunContextWrapper(context=None)
|
||||
|
||||
|
||||
# === Static Tool Filtering Tests ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_tool_filtering():
|
||||
"""Test all static tool filtering scenarios: allowed, blocked, both, none, etc."""
|
||||
server = FakeMCPServer(server_name="test_server")
|
||||
server.add_tool("tool1", {})
|
||||
server.add_tool("tool2", {})
|
||||
server.add_tool("tool3", {})
|
||||
server.add_tool("tool4", {})
|
||||
|
||||
# Create test context and agent for all calls
|
||||
run_context = create_test_context()
|
||||
agent = create_test_agent()
|
||||
|
||||
# Test allowed_tool_names only
|
||||
server.tool_filter = {"allowed_tool_names": ["tool1", "tool2"]}
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"tool1", "tool2"}
|
||||
|
||||
# Test blocked_tool_names only
|
||||
server.tool_filter = {"blocked_tool_names": ["tool3", "tool4"]}
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"tool1", "tool2"}
|
||||
|
||||
# Test both filters together (allowed first, then blocked)
|
||||
server.tool_filter = {
|
||||
"allowed_tool_names": ["tool1", "tool2", "tool3"],
|
||||
"blocked_tool_names": ["tool3"],
|
||||
}
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"tool1", "tool2"}
|
||||
|
||||
# Test no filter
|
||||
server.tool_filter = None
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 4
|
||||
|
||||
# Test helper function
|
||||
server.tool_filter = create_static_tool_filter(
|
||||
allowed_tool_names=["tool1", "tool2"], blocked_tool_names=["tool2"]
|
||||
)
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "tool1"
|
||||
|
||||
|
||||
# === Dynamic Tool Filtering Core Tests ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_filter_sync_and_async():
|
||||
"""Test both synchronous and asynchronous dynamic filters"""
|
||||
server = FakeMCPServer(server_name="test_server")
|
||||
server.add_tool("allowed_tool", {})
|
||||
server.add_tool("blocked_tool", {})
|
||||
server.add_tool("restricted_tool", {})
|
||||
|
||||
# Create test context and agent
|
||||
run_context = create_test_context()
|
||||
agent = create_test_agent()
|
||||
|
||||
# Test sync filter
|
||||
def sync_filter(context: ToolFilterContext, tool: MCPTool) -> bool:
|
||||
return tool.name.startswith("allowed")
|
||||
|
||||
server.tool_filter = sync_filter
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "allowed_tool"
|
||||
|
||||
# Test async filter
|
||||
async def async_filter(context: ToolFilterContext, tool: MCPTool) -> bool:
|
||||
await asyncio.sleep(0.001) # Simulate async operation
|
||||
return "restricted" not in tool.name
|
||||
|
||||
server.tool_filter = async_filter
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"allowed_tool", "blocked_tool"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_filter_context_handling():
|
||||
"""Test dynamic filters with context access"""
|
||||
server = FakeMCPServer(server_name="test_server")
|
||||
server.add_tool("admin_tool", {})
|
||||
server.add_tool("user_tool", {})
|
||||
server.add_tool("guest_tool", {})
|
||||
|
||||
# Test context-independent filter
|
||||
def context_independent_filter(context: ToolFilterContext, tool: MCPTool) -> bool:
|
||||
return not tool.name.startswith("admin")
|
||||
|
||||
server.tool_filter = context_independent_filter
|
||||
run_context = create_test_context()
|
||||
agent = create_test_agent()
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"user_tool", "guest_tool"}
|
||||
|
||||
# Test context-dependent filter (needs context)
|
||||
def context_dependent_filter(context: ToolFilterContext, tool: MCPTool) -> bool:
|
||||
assert context is not None
|
||||
assert context.run_context is not None
|
||||
assert context.agent is not None
|
||||
assert context.server_name == "test_server"
|
||||
|
||||
# Only admin tools for agents with "admin" in name
|
||||
if "admin" in context.agent.name.lower():
|
||||
return True
|
||||
else:
|
||||
return not tool.name.startswith("admin")
|
||||
|
||||
server.tool_filter = context_dependent_filter
|
||||
|
||||
# Should work with context
|
||||
run_context = RunContextWrapper(context=None)
|
||||
regular_agent = create_test_agent("regular_user")
|
||||
tools = await server.list_tools(run_context, regular_agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"user_tool", "guest_tool"}
|
||||
|
||||
admin_agent = create_test_agent("admin_user")
|
||||
tools = await server.list_tools(run_context, admin_agent)
|
||||
assert len(tools) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_filter_error_handling():
|
||||
"""Test error handling in dynamic filters"""
|
||||
server = FakeMCPServer(server_name="test_server")
|
||||
server.add_tool("good_tool", {})
|
||||
server.add_tool("error_tool", {})
|
||||
server.add_tool("another_good_tool", {})
|
||||
|
||||
def error_prone_filter(context: ToolFilterContext, tool: MCPTool) -> bool:
|
||||
if tool.name == "error_tool":
|
||||
raise ValueError("Simulated filter error")
|
||||
return True
|
||||
|
||||
server.tool_filter = error_prone_filter
|
||||
|
||||
# Test with server call
|
||||
run_context = create_test_context()
|
||||
agent = create_test_agent()
|
||||
tools = await server.list_tools(run_context, agent)
|
||||
assert len(tools) == 2
|
||||
assert {t.name for t in tools} == {"good_tool", "another_good_tool"}
|
||||
|
||||
|
||||
# === Integration Tests ===
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_dynamic_filtering_integration():
|
||||
"""Test dynamic filtering integration with Agent methods"""
|
||||
server = FakeMCPServer()
|
||||
server.add_tool("file_read", {"type": "object", "properties": {"path": {"type": "string"}}})
|
||||
server.add_tool(
|
||||
"file_write",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
server.add_tool(
|
||||
"database_query", {"type": "object", "properties": {"query": {"type": "string"}}}
|
||||
)
|
||||
server.add_tool(
|
||||
"network_request", {"type": "object", "properties": {"url": {"type": "string"}}}
|
||||
)
|
||||
|
||||
# Role-based filter for comprehensive testing
|
||||
async def role_based_filter(context: ToolFilterContext, tool: MCPTool) -> bool:
|
||||
# Simulate async permission check
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
agent_name = context.agent.name.lower()
|
||||
if "admin" in agent_name:
|
||||
return True
|
||||
elif "readonly" in agent_name:
|
||||
return "read" in tool.name or "query" in tool.name
|
||||
else:
|
||||
return tool.name.startswith("file_")
|
||||
|
||||
server.tool_filter = role_based_filter
|
||||
|
||||
# Test admin agent
|
||||
admin_agent = Agent(name="admin_user", instructions="Admin", mcp_servers=[server])
|
||||
run_context = RunContextWrapper(context=None)
|
||||
admin_tools = await admin_agent.get_mcp_tools(run_context)
|
||||
assert len(admin_tools) == 4
|
||||
|
||||
# Test readonly agent
|
||||
readonly_agent = Agent(name="readonly_viewer", instructions="Read-only", mcp_servers=[server])
|
||||
readonly_tools = await readonly_agent.get_mcp_tools(run_context)
|
||||
assert len(readonly_tools) == 2
|
||||
assert {t.name for t in readonly_tools} == {"file_read", "database_query"}
|
||||
|
||||
# Test regular agent
|
||||
regular_agent = Agent(name="regular_user", instructions="Regular", mcp_servers=[server])
|
||||
regular_tools = await regular_agent.get_mcp_tools(run_context)
|
||||
assert len(regular_tools) == 2
|
||||
assert {t.name for t in regular_tools} == {"file_read", "file_write"}
|
||||
|
||||
# Test get_all_tools method
|
||||
all_tools = await regular_agent.get_all_tools(run_context)
|
||||
mcp_tool_names = {
|
||||
t.name
|
||||
for t in all_tools
|
||||
if t.name in {"file_read", "file_write", "database_query", "network_request"}
|
||||
}
|
||||
assert mcp_tool_names == {"file_read", "file_write"}
|
||||
Reference in New Issue
Block a user