chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
View File
+164
View File
@@ -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
+63
View File
@@ -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
+574
View File
@@ -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
+69
View File
@@ -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"
+242
View File
@@ -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},
]
+179
View File
@@ -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,
)
+73
View File
@@ -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__)
+175
View File
@@ -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")
+552
View File
@@ -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()
+274
View File
@@ -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
+133
View File
@@ -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
+324
View File
@@ -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"
+402
View File
@@ -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
+85
View File
@@ -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
+246
View File
@@ -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"}