Files
2026-07-13 13:12:00 +08:00

2428 lines
78 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for omnigent.tools.mcp (MCP connections and tools)."""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cachetools import TTLCache
from mcp.shared.exceptions import McpError
from mcp.types import CONNECTION_CLOSED, CallToolResult, ErrorData, ImageContent, TextContent
from mcp.types import Tool as McpToolDef
from omnigent.spec.types import MCPServerConfig, RetryPolicy
from omnigent.tools.mcp import (
_CIRCUIT_BREAKER_COOLDOWN_SECONDS,
_CIRCUIT_BREAKER_THRESHOLD,
_MCP_RECONNECT_DEFAULTS,
McpElicitationRequired,
McpServerConnection,
McpServerDisabledError,
_backoff_delay,
_cache_key,
_CircuitBreaker,
_collect_problematic_keywords,
_discovery_cache,
_format_call_result,
_is_connection_error,
_normalize_input_schema,
clear_discovery_cache,
)
@pytest.fixture(autouse=True)
def _clean_cache() -> None:
"""
Clear the module-level discovery cache before each test.
"""
clear_discovery_cache()
def _make_http_config(
name: str = "test-server",
url: str = "http://localhost:9000/mcp",
) -> MCPServerConfig:
"""
Create a minimal HTTP MCP server config.
:param name: Server name identifier.
:param url: Server endpoint URL.
:returns: An ``MCPServerConfig`` for HTTP transport.
"""
return MCPServerConfig(
name=name,
url=url,
)
def _make_mcp_tool_def(
name: str = "test_tool",
description: str = "A test tool.",
) -> MagicMock:
"""
Create a mock MCP tool definition matching ``mcp.types.Tool``.
Uses a MagicMock because we only read ``.name``,
``.description``, and ``.inputSchema`` — these are plain
attribute reads, not isinstance checks.
:param name: Tool name.
:param description: Tool description.
:returns: A mock with name, description, and inputSchema.
"""
tool_def = MagicMock()
tool_def.name = name
tool_def.description = description
tool_def.inputSchema = {
"type": "object",
"properties": {
"query": {"type": "string"},
},
"required": ["query"],
}
return tool_def
@contextmanager
def _mock_mcp_transport(
tools: list[MagicMock] | None = None,
) -> Iterator[AsyncMock]:
"""
Mock the MCP transport and session for ``connect()`` tests.
Patches ``sse_client`` and ``ClientSession`` so that
``McpServerConnection.connect()`` can run without a real
MCP server. The mock session's ``list_tools()`` returns
the provided tool definitions.
:param tools: Mock tool definitions to return from
``list_tools()``. Defaults to an empty list.
:yields: The mock ``ClientSession`` instance.
"""
mock_session = AsyncMock()
mock_tools_result = MagicMock()
mock_tools_result.tools = tools or []
mock_session.list_tools.return_value = mock_tools_result
mock_session.initialize = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_ctx = AsyncMock()
# streamablehttp_client yields (read, write, get_session_id)
mock_ctx.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock(), MagicMock()))
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"omnigent.tools.mcp.streamablehttp_client",
return_value=mock_ctx,
):
with patch(
"omnigent.tools.mcp.ClientSession",
return_value=mock_session,
):
yield mock_session
# ── _cache_key ───────────────────────────────────────────
def test_cache_key_includes_name_and_url() -> None:
"""
Cache key includes the server name and URL.
"""
config = _make_http_config()
key = _cache_key(config)
assert "http" in key
assert "test-server" in key
assert "localhost:9000" in key
def test_cache_key_different_configs_differ() -> None:
"""
Different server configs produce different cache keys.
"""
key1 = _cache_key(_make_http_config("server-a"))
key2 = _cache_key(_make_http_config("server-b"))
assert key1 != key2
def test_cache_key_stdio_includes_command_and_args() -> None:
"""
Stdio MCP cache key includes the transport tag, name,
command, and joined args — so two stdio configs that share
a name but point at different subprocesses don't collide in
the module-level ``_discovery_cache``.
What breaks if this fails: two agents that both declare a
``glean`` MCP — one pointing at ``--profile dogfood`` and
another at ``--profile prod`` — would share one cache entry
and one would see the other's discovered tools.
"""
config = MCPServerConfig(
name="stdio-server",
transport="stdio",
command="npx",
args=["-y", "@modelcontextprotocol/server-github"],
)
key = _cache_key(config)
# Tag identifies the transport so stdio + http with the same
# name never collide.
assert key.startswith("stdio:")
assert "stdio-server" in key
assert "npx" in key
# Arg content matters — different args means different cache
# entries, not a silent share.
assert "@modelcontextprotocol/server-github" in key
def test_cache_key_stdio_args_changes_key() -> None:
"""
Different args on the same stdio command produce different
cache keys — covers the realistic "same binary, different
CLI flags" case (e.g. databricks MCPs differentiated by
``--profile``).
What breaks if this fails: the cache treats
``my-mcp --profile prod`` and ``my-mcp --profile dev`` as
the same server, silently cross-contaminating discovery
results between agent runs.
"""
base = {
"name": "my-mcp",
"transport": "stdio",
"command": "my-mcp",
}
key_prod = _cache_key(MCPServerConfig(args=["--profile", "prod"], **base))
key_dev = _cache_key(MCPServerConfig(args=["--profile", "dev"], **base))
assert key_prod != key_dev
def test_cache_key_stdio_and_http_do_not_collide() -> None:
"""
A stdio server named ``my-mcp`` and an HTTP server named
``my-mcp`` produce different keys — the transport tag is
the first segment of the key so same-name, different-
transport never shares a cache entry.
What breaks if this fails: an HTTP MCP named ``foo``'s tool
list would get served to an agent declaring a stdio MCP
also named ``foo`` (or vice versa).
"""
http = MCPServerConfig(name="foo", url="http://mcp.example.com")
stdio = MCPServerConfig(name="foo", transport="stdio", command="foo")
assert _cache_key(http) != _cache_key(stdio)
# ── McpServerConnection caching ──────────────────────────
@pytest.mark.asyncio()
async def test_connect_skips_list_tools_when_cache_fresh() -> None:
"""
``connect()`` skips the ``list_tools()`` round-trip when
the cache is fresh, but still opens a live session so
``call_tool()`` works.
"""
config = _make_http_config()
tool_def = _make_mcp_tool_def()
# Pre-populate the cache — TTLCache uses dict assignment.
_discovery_cache[_cache_key(config)] = [tool_def]
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
tools = await conn.connect()
assert len(tools) == 1
assert tools[0].name == "test_tool"
# Session was created (for invocation), but list_tools
# was NOT called (served from cache).
mock_session.initialize.assert_awaited_once()
mock_session.list_tools.assert_not_awaited()
await conn.close()
@pytest.mark.asyncio()
async def test_cached_connect_has_live_session() -> None:
"""
When discovery is served from cache, the connection still
has a live session that can invoke tools.
"""
config = _make_http_config()
_discovery_cache[_cache_key(config)] = [_make_mcp_tool_def()]
with _mock_mcp_transport() as mock_session:
# Set up call_tool to return a mock result.
mock_result = MagicMock()
mock_result.content = [TextContent(type="text", text="cached ok")]
mock_result.isError = False
mock_session.call_tool.return_value = mock_result
conn = McpServerConnection(config=config)
await conn.connect()
result = await conn.call_tool("test_tool", {"query": "hi"})
assert result == "cached ok"
mock_session.call_tool.assert_awaited_once()
await conn.close()
@pytest.mark.asyncio()
async def test_connect_skips_expired_cache() -> None:
"""
``connect()`` ignores cache entries older than the TTL and
performs a live discovery via ``list_tools()``.
"""
config = _make_http_config()
# Use a TTLCache with a controllable timer so we can
# simulate expiry without sleeping. Start at t=0, insert
# the entry, then advance past the TTL.
current_time = [0.0]
expired_cache: TTLCache[str, list[MagicMock]] = TTLCache(
maxsize=64,
ttl=300,
timer=lambda: current_time[0],
)
expired_cache[_cache_key(config)] = [_make_mcp_tool_def()]
# Advance time past the 300s TTL.
current_time[0] = 1000.0
fresh_tool = _make_mcp_tool_def("fresh_tool")
with _mock_mcp_transport([fresh_tool]) as mock_session:
with patch("omnigent.tools.mcp._discovery_cache", expired_cache):
conn = McpServerConnection(config=config)
tools = await conn.connect()
assert len(tools) == 1
assert tools[0].name == "fresh_tool"
mock_session.initialize.assert_awaited_once()
mock_session.list_tools.assert_awaited_once()
await conn.close()
@pytest.mark.asyncio()
async def test_connect_populates_cache() -> None:
"""
A successful live ``connect()`` stores results in the
module-level cache.
"""
config = _make_http_config()
tool_def = _make_mcp_tool_def("cached_tool")
with _mock_mcp_transport([tool_def]):
conn = McpServerConnection(config=config)
await conn.connect()
key = _cache_key(config)
assert key in _discovery_cache
cached = _discovery_cache.get(key)
assert cached is not None
assert len(cached) == 1
assert cached[0].name == "cached_tool"
await conn.close()
# ── McpServerConnection.call_tool ────────────────────────
@pytest.mark.asyncio()
async def test_call_tool_raises_without_connect() -> None:
"""
``call_tool()`` raises RuntimeError when ``connect()``
was never called.
"""
conn = McpServerConnection(config=_make_http_config())
with pytest.raises(RuntimeError, match="no live session"):
await conn.call_tool("test_tool", {"query": "hi"})
# ── McpServerConnection.close ────────────────────────────
@pytest.mark.asyncio()
async def test_close_is_safe_when_never_connected() -> None:
"""
``close()`` does not raise if ``connect()`` was never called.
"""
conn = McpServerConnection(config=_make_http_config())
await conn.close()
def test_mcp_server_config_repr_redacts_headers() -> None:
"""
MCPServerConfig.__repr__ replaces header values with
``[REDACTED]`` so credentials don't leak in logs or
exception tracebacks.
If the real Authorization token appeared in repr, it would
be captured by ``_logger.exception()`` in manager.py when
MCP connections fail.
"""
config = MCPServerConfig(
name="secret-svc",
url="http://example.com/sse",
headers={
"Authorization": "Bearer sk-SUPER-SECRET-TOKEN",
"X-Custom": "also-secret",
},
)
r = repr(config)
# Header keys are visible (useful for debugging which headers are set)
assert "Authorization" in r
assert "X-Custom" in r
# Actual secret values must NOT appear
assert "sk-SUPER-SECRET-TOKEN" not in r
assert "also-secret" not in r
# Redaction marker is present
assert "[REDACTED]" in r
# Non-sensitive fields are still visible
assert "secret-svc" in r
assert "http://example.com/sse" in r
def test_mcp_server_config_repr_empty_headers() -> None:
"""
Repr works correctly when there are no headers — no crash,
no ``[REDACTED]`` in the output.
"""
config = MCPServerConfig(name="plain", url="http://localhost/sse")
r = repr(config)
assert "plain" in r
assert "http://localhost/sse" in r
assert "[REDACTED]" not in r
# ── _normalize_input_schema ───────────────────────────────
def test_normalize_none_schema_returns_empty_object() -> None:
"""
``None`` inputSchema (tool has no parameters) is normalized
to a valid empty object schema.
"""
result = _normalize_input_schema(None, "no_args_tool")
assert result == {"type": "object", "properties": {}}
def test_normalize_missing_properties_injects_empty() -> None:
"""
A schema with ``type: object`` but no ``properties`` key
gets ``properties: {}`` injected. OpenAI rejects schemas
without this key (openai/openai-agents-python#449).
"""
schema = {"type": "object"}
result = _normalize_input_schema(schema, "bare_object_tool")
assert result["properties"] == {}
assert result["type"] == "object"
def test_normalize_preserves_existing_properties() -> None:
"""
A schema that already has ``properties`` is returned as-is
(no double-injection).
"""
schema = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
result = _normalize_input_schema(schema, "normal_tool")
assert result["properties"] == {"query": {"type": "string"}}
assert result["required"] == ["query"]
def test_normalize_does_not_mutate_original_schema() -> None:
"""
``_normalize_input_schema`` returns a new dict when
modifying — it does not mutate the original schema dict.
"""
original = {"type": "object"}
result = _normalize_input_schema(original, "test")
# Result has properties injected.
assert "properties" in result
# Original is untouched.
assert "properties" not in original
def test_normalize_non_object_schema_unchanged() -> None:
"""
A schema with a non-object type (e.g. ``array``) is not
modified — ``properties`` injection only applies to objects.
"""
schema = {"type": "array", "items": {"type": "string"}}
result = _normalize_input_schema(schema, "array_tool")
assert result == schema
assert "properties" not in result
def test_normalize_warns_on_ref(caplog: pytest.LogCaptureFixture) -> None:
"""
A schema containing ``$ref`` triggers a warning log.
"""
schema = {
"type": "object",
"properties": {
"item": {"$ref": "#/$defs/Item"},
},
"$defs": {
"Item": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
},
}
_normalize_input_schema(schema, "ref_tool")
assert any("$ref" in msg for msg in caplog.messages)
def test_normalize_warns_on_oneof(caplog: pytest.LogCaptureFixture) -> None:
"""
A schema containing ``oneOf`` triggers a warning log.
"""
schema = {
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string"},
{"type": "integer"},
],
},
},
}
_normalize_input_schema(schema, "oneof_tool")
assert any("oneOf" in msg for msg in caplog.messages)
def test_normalize_no_warning_for_clean_schema(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A clean schema with no problematic keywords produces no
warnings.
"""
schema = {
"type": "object",
"properties": {"q": {"type": "string"}},
}
_normalize_input_schema(schema, "clean_tool")
assert not any("reject" in msg or "inconsistent" in msg for msg in caplog.messages)
# ── _collect_problematic_keywords ─────────────────────────
def test_collect_finds_ref_in_properties() -> None:
"""
``$ref`` nested inside a property is detected.
"""
schema = {
"type": "object",
"properties": {
"item": {"$ref": "#/$defs/Item"},
},
}
assert "$ref" in _collect_problematic_keywords(schema)
def test_collect_finds_oneof_in_nested_property() -> None:
"""
``oneOf`` inside a nested property is detected.
"""
schema = {
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string"},
{"type": "integer"},
],
},
},
}
assert "oneOf" in _collect_problematic_keywords(schema)
def test_collect_finds_keywords_in_array_items() -> None:
"""
Problematic keywords inside ``items`` of an array are
detected.
"""
schema = {
"type": "array",
"items": {
"type": "object",
"properties": {
"x": {"$ref": "#/$defs/X"},
},
},
}
assert "$ref" in _collect_problematic_keywords(schema)
def test_collect_finds_keywords_in_defs() -> None:
"""
Problematic keywords inside ``$defs`` are detected.
"""
schema = {
"type": "object",
"properties": {},
"$defs": {
"Node": {
"type": "object",
"properties": {
"child": {"$ref": "#/$defs/Node"},
},
},
},
}
assert "$ref" in _collect_problematic_keywords(schema)
def test_collect_returns_empty_for_clean_schema() -> None:
"""
A clean schema returns an empty set.
"""
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"count": {"type": "integer"},
},
}
assert _collect_problematic_keywords(schema) == set()
# ── _format_call_result ──────────────────────────────────
def test_format_call_result_text_content() -> None:
"""
Text content blocks are extracted and joined.
"""
block = TextContent(type="text", text="Hello world")
result = MagicMock()
result.content = [block]
result.isError = False
assert _format_call_result(result) == "Hello world"
def test_format_call_result_multiple_blocks() -> None:
"""
Multiple text blocks are joined with newlines.
"""
block1 = TextContent(type="text", text="Line 1")
block2 = TextContent(type="text", text="Line 2")
result = MagicMock()
result.content = [block1, block2]
result.isError = False
assert _format_call_result(result) == "Line 1\nLine 2"
def test_format_call_result_error_prefix() -> None:
"""
Error results are prefixed with "Error: ".
"""
block = TextContent(type="text", text="something went wrong")
result = MagicMock()
result.content = [block]
result.isError = True
formatted = _format_call_result(result)
assert formatted.startswith("Error: ")
assert "something went wrong" in formatted
def test_format_call_result_non_text_content() -> None:
"""
Non-text content (e.g. images) is serialized as JSON via
``model_dump()``.
"""
block = ImageContent(type="image", data="base64data", mimeType="image/png")
result = MagicMock()
result.content = [block]
result.isError = False
formatted = _format_call_result(result)
parsed = json.loads(formatted)
assert parsed["type"] == "image"
assert parsed["data"] == "base64data"
def test_format_call_result_empty_content() -> None:
"""
An empty content list returns ``"(empty response)"``
instead of a blank string.
"""
result = MagicMock()
result.content = []
result.isError = False
assert _format_call_result(result) == "(empty response)"
def test_format_call_result_empty_content_with_error() -> None:
"""
An empty content list with ``isError=True`` returns
``"Error: (empty response)"``.
"""
result = MagicMock()
result.content = []
result.isError = True
assert _format_call_result(result) == "Error: (empty response)"
# ── clear_discovery_cache ────────────────────────────────
def test_clear_discovery_cache() -> None:
"""
``clear_discovery_cache()`` empties the module-level cache.
"""
config = _make_http_config()
_discovery_cache[_cache_key(config)] = []
assert len(_discovery_cache) > 0
clear_discovery_cache()
assert len(_discovery_cache) == 0
def test_discovery_cache_evicts_lru_when_full() -> None:
"""
The discovery cache evicts the least-recently-used entry
when it reaches ``maxsize``.
Uses a small TTLCache (maxsize=2) to verify that inserting
a third entry evicts the oldest one.
"""
small_cache: TTLCache[str, list[MagicMock]] = TTLCache(
maxsize=2,
ttl=300,
)
small_cache["server-a"] = [_make_mcp_tool_def("tool_a")]
small_cache["server-b"] = [_make_mcp_tool_def("tool_b")]
# Inserting a third entry should evict the LRU (server-a).
small_cache["server-c"] = [_make_mcp_tool_def("tool_c")]
assert "server-a" not in small_cache
assert "server-b" in small_cache
assert "server-c" in small_cache
assert len(small_cache) == 2
# ── _run_async ───────────────────────────────────────────
# ── _is_connection_error ─────────────────────────────────
def test_is_connection_error_eof() -> None:
"""
EOFError is classified as a connection error.
"""
assert _is_connection_error(EOFError()) is True
def test_is_connection_error_broken_pipe() -> None:
"""
BrokenPipeError is classified as a connection error.
"""
assert _is_connection_error(BrokenPipeError()) is True
def test_is_connection_error_connection_reset() -> None:
"""
ConnectionResetError (subclass of ConnectionError) is
classified as a connection error.
"""
assert _is_connection_error(ConnectionResetError()) is True
def test_is_connection_error_mcp_connection_closed() -> None:
"""
McpError with CONNECTION_CLOSED code is classified as a
connection error.
"""
exc = McpError(
ErrorData(
code=CONNECTION_CLOSED,
message="Connection closed",
)
)
assert _is_connection_error(exc) is True
def test_is_connection_error_mcp_other_code() -> None:
"""
McpError with a non-connection code (e.g. INVALID_PARAMS)
is NOT classified as a connection error.
"""
exc = McpError(
ErrorData(
code=-32602, # INVALID_PARAMS
message="Invalid params",
)
)
assert _is_connection_error(exc) is False
def test_is_connection_error_value_error() -> None:
"""
ValueError is NOT classified as a connection error.
"""
assert _is_connection_error(ValueError("bad")) is False
# ── _backoff_delay ────────────────────────────────────────
def test_backoff_delay_increases_with_attempt() -> None:
"""
``_backoff_delay`` increases with each attempt (exponential
backoff) and applies jitter (0.51.5x).
"""
retry = RetryPolicy(backoff_base_s=2.0, backoff_max_s=30.0)
# Attempt 0 → retry_index=1: base * 2^0 = 2.0, jitter [1.0, 3.0].
delay_0 = _backoff_delay(0, retry)
assert 1.0 <= delay_0 <= 3.0
# Attempt 1 → retry_index=2: base * 2^1 = 4.0, jitter [2.0, 6.0].
delay_1 = _backoff_delay(1, retry)
assert 2.0 <= delay_1 <= 6.0
# Attempt 2 → retry_index=3: base * 2^2 = 8.0, jitter [4.0, 12.0].
delay_2 = _backoff_delay(2, retry)
assert 4.0 <= delay_2 <= 12.0
def test_backoff_delay_capped_at_max() -> None:
"""
``_backoff_delay`` never exceeds ``backoff_max_s`` (before
jitter).
"""
retry = RetryPolicy(backoff_base_s=10.0, backoff_max_s=5.0)
# 10 * 2^0 = 10, capped to 5.0; jitter[0.5, 1.5] → [2.5, 7.5].
delay = _backoff_delay(0, retry)
assert delay <= 7.5
# ── Reconnection on server death ─────────────────────────
@pytest.mark.asyncio()
async def test_call_tool_reconnects_on_connection_error() -> None:
"""
When a tool call fails with a connection error, the
connection reconnects with backoff and retries.
"""
config = _make_http_config()
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
# First call_tool raises a connection error (server died).
# Second call_tool (after reconnect) succeeds.
ok_result = MagicMock()
ok_result.content = [TextContent(type="text", text="recovered")]
ok_result.isError = False
mock_session.call_tool.side_effect = [
EOFError("server died"),
ok_result,
]
# Patch _reconnect to re-establish the mock session
# (in production this opens a new transport).
# Patch the _sleep indirection so retry backoff is instant.
with patch.object(conn, "_reconnect", new_callable=AsyncMock) as mock_reconnect:
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
result = await conn.call_tool("test_tool", {"query": "hi"})
assert result == "recovered"
mock_reconnect.assert_awaited_once()
await conn.close()
@pytest.mark.asyncio()
async def test_call_tool_does_not_reconnect_on_tool_error() -> None:
"""
When a tool call fails with a non-connection error (e.g.
McpError for invalid params), no reconnect is attempted.
"""
config = _make_http_config()
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
mock_session.call_tool.side_effect = McpError(
ErrorData(
code=-32602,
message="Invalid params",
)
)
with pytest.raises(McpError):
await conn.call_tool("test_tool", {"query": "hi"})
await conn.close()
@pytest.mark.asyncio()
async def test_call_tool_exhausts_all_retries_then_raises() -> None:
"""
When all reconnect attempts fail, the last connection
error is propagated to the caller.
"""
# max_retries=2 means 2 retries beyond the first attempt,
# so 3 invoke calls total (1 initial + 2 retries).
config = MCPServerConfig(
name="test-retry-exhaust",
url="http://localhost:9000/mcp",
retry=RetryPolicy(
max_retries=2,
backoff_base_s=1.0,
backoff_max_s=10.0,
),
)
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
mock_session.call_tool.side_effect = [
EOFError("attempt 1"),
EOFError("attempt 2"),
EOFError("attempt 3"),
]
with patch.object(conn, "_reconnect", new_callable=AsyncMock):
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
with pytest.raises(EOFError, match="attempt 3"):
await conn.call_tool("test_tool", {"query": "hi"})
# 3 invoke calls, 2 reconnects (no reconnect after last failure).
assert mock_session.call_tool.await_count == 3
@pytest.mark.asyncio()
async def test_call_tool_uses_config_retry_policy() -> None:
"""
``call_tool()`` uses the per-server ``config.retry`` when
set, rather than the module-level default.
"""
# max_retries=1 means 1 retry beyond first attempt — 2 invoke calls total.
config = MCPServerConfig(
name="test-custom-retry",
url="http://localhost:9000/mcp",
retry=RetryPolicy(
max_retries=1,
backoff_base_s=0.5,
backoff_max_s=5.0,
),
)
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
mock_session.call_tool.side_effect = [
EOFError("attempt 1"),
EOFError("attempt 2"),
]
with patch.object(conn, "_reconnect", new_callable=AsyncMock):
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
with pytest.raises(EOFError, match="attempt 2"):
await conn.call_tool("test_tool", {"query": "hi"})
assert mock_session.call_tool.await_count == 2
@pytest.mark.asyncio()
async def test_call_tool_sleeps_between_retries() -> None:
"""
``call_tool()`` sleeps with backoff between reconnect
attempts. Verifies that the ``_sleep`` indirection is called
with increasing delays.
"""
# max_retries=2 → 3 total attempts (2 errors + 1 success).
config = MCPServerConfig(
name="test-backoff",
url="http://localhost:9000/mcp",
retry=RetryPolicy(
max_retries=2,
backoff_base_s=2.0,
backoff_max_s=30.0,
),
)
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
ok_result = MagicMock()
ok_result.content = [TextContent(type="text", text="ok")]
ok_result.isError = False
mock_session.call_tool.side_effect = [
EOFError("attempt 1"),
EOFError("attempt 2"),
ok_result,
]
with patch.object(conn, "_reconnect", new_callable=AsyncMock):
with patch(
"omnigent.tools.mcp._sleep",
new_callable=AsyncMock,
) as mock_sleep:
result = await conn.call_tool("test_tool", {"query": "hi"})
assert result == "ok"
# Two sleeps: before retry 1 and before retry 2.
assert mock_sleep.await_count == 2
# Delays computed by RetryPolicy.compute_backoff_delay:
# retry_index=1 → 2.0 * 2^0 = 2.0; retry_index=2 → 2.0 * 2^1 = 4.0.
# Jitter is uniform[0.5, 1.5], so delay 1 ∈ [1.0, 3.0],
# delay 2 ∈ [2.0, 6.0].
delay_1 = mock_sleep.await_args_list[0].args[0]
delay_2 = mock_sleep.await_args_list[1].args[0]
assert 1.0 <= delay_1 <= 3.0
assert 2.0 <= delay_2 <= 6.0
@pytest.mark.asyncio()
async def test_call_tool_default_retry_has_three_attempts() -> None:
"""
When ``config.retry`` is ``None``, ``call_tool()`` falls
back to ``_MCP_RECONNECT_DEFAULTS`` which allows 3 attempts.
"""
# No retry config — should use default (3 attempts).
config = MCPServerConfig(
name="test-default-retry",
url="http://localhost:9000/mcp",
# retry defaults to None
)
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
mock_session.call_tool.side_effect = [
EOFError("attempt 1"),
EOFError("attempt 2"),
EOFError("attempt 3"),
]
with patch.object(conn, "_reconnect", new_callable=AsyncMock):
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
with pytest.raises(EOFError, match="attempt 3"):
await conn.call_tool("test_tool", {"query": "hi"})
# total attempts = max_retries + 1 (initial + retries).
assert mock_session.call_tool.await_count == _MCP_RECONNECT_DEFAULTS.max_retries + 1
# ── Timeout propagation ──────────────────────────────────
@pytest.mark.asyncio()
async def test_connect_passes_timeout_to_client_session() -> None:
"""
When ``MCPServerConfig.timeout`` is set, ``connect()`` must
pass ``read_timeout_seconds=timedelta(seconds=timeout)`` to
``ClientSession``.
"""
config = MCPServerConfig(
name="test-timeout",
url="http://localhost:9000/mcp",
timeout=60,
)
captured_kwargs: dict[str, Any] = {}
def _capturing_session(
*args: Any,
**kwargs: Any,
) -> AsyncMock:
"""
Fake ``ClientSession`` constructor that records kwargs.
:param args: Positional args (read_stream, write_stream).
:param kwargs: Keyword args including read_timeout_seconds.
:returns: A mock session with working initialize/list_tools.
"""
captured_kwargs.update(kwargs)
mock_session = AsyncMock()
mock_session.initialize = AsyncMock()
mock_tools = MagicMock()
mock_tools.tools = []
mock_session.list_tools.return_value = mock_tools
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
return mock_session
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(
return_value=(MagicMock(), MagicMock(), MagicMock()),
)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"omnigent.tools.mcp.ClientSession",
side_effect=_capturing_session,
):
with patch(
"omnigent.tools.mcp.streamablehttp_client",
return_value=mock_ctx,
):
conn = McpServerConnection(config=config)
await conn.connect()
# timeout=60 must be converted to timedelta(seconds=60) for the
# MCP SDK's ClientSession read_timeout_seconds parameter.
assert captured_kwargs.get("read_timeout_seconds") == timedelta(seconds=60), (
"ClientSession must receive read_timeout_seconds as a "
"timedelta matching the config timeout"
)
await conn.close()
@pytest.mark.asyncio()
async def test_connect_passes_none_timeout_to_client_session() -> None:
"""
When ``MCPServerConfig.timeout`` is ``None`` (default),
``connect()`` must pass ``read_timeout_seconds=None`` so the
MCP SDK uses its built-in default.
"""
config = MCPServerConfig(
name="test-no-timeout",
url="http://localhost:9000/mcp",
# timeout defaults to None
)
captured_kwargs: dict[str, Any] = {}
def _capturing_session(
*args: Any,
**kwargs: Any,
) -> AsyncMock:
"""
Fake ``ClientSession`` constructor that records kwargs.
:param args: Positional args (read_stream, write_stream).
:param kwargs: Keyword args including read_timeout_seconds.
:returns: A mock session with working initialize/list_tools.
"""
captured_kwargs.update(kwargs)
mock_session = AsyncMock()
mock_session.initialize = AsyncMock()
mock_tools = MagicMock()
mock_tools.tools = []
mock_session.list_tools.return_value = mock_tools
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
return mock_session
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(
return_value=(MagicMock(), MagicMock(), MagicMock()),
)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"omnigent.tools.mcp.ClientSession",
side_effect=_capturing_session,
):
with patch(
"omnigent.tools.mcp.streamablehttp_client",
return_value=mock_ctx,
):
conn = McpServerConnection(config=config)
await conn.connect()
# When timeout is None, read_timeout_seconds must be None so the
# MCP SDK falls back to its own default (no timeout).
assert captured_kwargs.get("read_timeout_seconds") is None, (
"ClientSession must receive read_timeout_seconds=None when config timeout is unset"
)
await conn.close()
@pytest.mark.asyncio()
async def test_connect_http_passes_timeout_to_transport() -> None:
"""
When ``MCPServerConfig(timeout=60)``, ``connect()`` must pass
``timeout=60.0`` and ``sse_read_timeout=60.0`` to the
Streamable HTTP transport client.
If the timeout is not forwarded, the transport uses its
default (30s), and long-running MCP tool calls would time out
prematurely.
"""
config = MCPServerConfig(
name="test-http-timeout",
url="http://localhost:9000/mcp",
timeout=60,
)
with _mock_http_transport() as captured:
conn = McpServerConnection(config=config)
await conn.connect()
assert captured.transport_kwargs["timeout"] == 60.0, (
"transport timeout must equal the config timeout as float"
)
assert captured.transport_kwargs["sse_read_timeout"] == 60.0, (
"transport sse_read_timeout must equal the config timeout as float"
)
await conn.close()
@pytest.mark.asyncio()
async def test_connect_http_uses_default_timeouts_when_none() -> None:
"""
When ``MCPServerConfig(timeout=None)``, ``connect()`` must
pass SDK defaults: ``timeout=30`` and ``sse_read_timeout=300``
to the Streamable HTTP transport.
If the defaults are wrong, connections may hang indefinitely
or fail immediately.
"""
config = MCPServerConfig(
name="test-http-default",
url="http://localhost:9000/mcp",
# timeout defaults to None
)
with _mock_http_transport() as captured:
conn = McpServerConnection(config=config)
await conn.connect()
# Streamable HTTP default: 30s for request timeout.
assert captured.transport_kwargs["timeout"] == 30, (
"transport timeout must default to 30 when config timeout is None"
)
# SDK default: 300s (5 min) for SSE event read.
assert captured.transport_kwargs["sse_read_timeout"] == 300, (
"transport sse_read_timeout must default to 300 when config timeout is None"
)
# ── HTTP transport: connection, headers, discovery ────────
@dataclass
class CapturedHttpArgs:
"""
Container for kwargs captured from ``streamablehttp_client``
and ``ClientSession`` during HTTP transport tests.
:param transport_kwargs: Keyword arguments passed to
``streamablehttp_client``, e.g.
``{"url": "...", "headers": {...}, "timeout": 30}``.
:param session_kwargs: Keyword arguments passed to
``ClientSession``, e.g. ``{"read_timeout_seconds": ...}``.
:param mock_session: The mock ``ClientSession`` instance for
setting up ``call_tool()`` side effects.
"""
transport_kwargs: dict[str, Any] = field(default_factory=dict)
session_kwargs: dict[str, Any] = field(default_factory=dict)
mock_session: AsyncMock = field(default_factory=AsyncMock)
@contextmanager
def _mock_http_transport(
tools: list[MagicMock] | None = None,
) -> Iterator[CapturedHttpArgs]:
"""
Mock the Streamable HTTP transport and session for HTTP tests.
Patches ``streamablehttp_client`` and ``ClientSession`` so that
``McpServerConnection.connect()`` can run without a real
HTTP server. Captures the kwargs passed to both so tests
can verify URL, headers, timeout, and other arguments.
:param tools: Mock tool definitions to return from
``list_tools()``. Defaults to an empty list.
:yields: A :class:`CapturedHttpArgs` with captured kwargs
and the mock session.
"""
captured = CapturedHttpArgs()
mock_streamable_ctx = AsyncMock()
mock_streamable_ctx.__aenter__ = AsyncMock(
return_value=(MagicMock(), MagicMock(), MagicMock()),
)
mock_streamable_ctx.__aexit__ = AsyncMock(return_value=False)
def _capturing_streamable_client(**kwargs: Any) -> AsyncMock:
"""
Fake ``streamablehttp_client`` that records kwargs.
:param kwargs: Keyword args including url, headers,
timeout, and sse_read_timeout.
:returns: An async context manager yielding mock streams
and a session-id getter.
"""
captured.transport_kwargs.update(kwargs)
return mock_streamable_ctx
def _capturing_session(
*args: Any,
**kwargs: Any,
) -> AsyncMock:
"""
Fake ``ClientSession`` constructor that records kwargs.
:param args: Positional args (read_stream, write_stream).
:param kwargs: Keyword args including read_timeout_seconds.
:returns: A mock session with working initialize/list_tools.
"""
captured.session_kwargs.update(kwargs)
mock_session = AsyncMock()
mock_session.initialize = AsyncMock()
mock_tools_result = MagicMock()
mock_tools_result.tools = tools or []
mock_session.list_tools.return_value = mock_tools_result
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
captured.mock_session = mock_session
return mock_session
with patch(
"omnigent.tools.mcp.streamablehttp_client",
side_effect=_capturing_streamable_client,
):
with patch(
"omnigent.tools.mcp.ClientSession",
side_effect=_capturing_session,
):
yield captured
@pytest.mark.asyncio()
async def test_http_connect_passes_url_to_transport() -> None:
"""
HTTP ``connect()`` passes the config URL to the transport client.
"""
config = MCPServerConfig(
name="test-http",
url="https://mcp.example.com/mcp",
)
with _mock_http_transport() as captured:
conn = McpServerConnection(config=config)
await conn.connect()
assert captured.transport_kwargs["url"] == "https://mcp.example.com/mcp"
await conn.close()
@pytest.mark.asyncio()
async def test_http_connect_passes_headers_to_transport() -> None:
"""
HTTP ``connect()`` propagates auth headers from the config
to the transport client.
"""
config = MCPServerConfig(
name="test-http-headers",
url="http://localhost:9000/mcp",
headers={
"Authorization": "Bearer tok_xyz",
"X-Custom": "value",
},
)
with _mock_http_transport() as captured:
conn = McpServerConnection(config=config)
await conn.connect()
assert captured.transport_kwargs["headers"] == {
"Authorization": "Bearer tok_xyz",
"X-Custom": "value",
}
await conn.close()
@pytest.mark.asyncio()
async def test_http_connect_passes_none_headers_when_empty() -> None:
"""
HTTP ``connect()`` passes ``headers=None`` when the config
has no headers, so the transport client uses its default.
"""
config = MCPServerConfig(
name="test-http-no-headers",
url="http://localhost:9000/mcp",
# headers defaults to empty dict
)
with _mock_http_transport() as captured:
conn = McpServerConnection(config=config)
await conn.connect()
# Empty dict is converted to None via `or None`.
assert captured.transport_kwargs["headers"] is None
await conn.close()
@pytest.mark.asyncio()
async def test_http_falls_back_to_sse_when_streamable_fails() -> None:
"""
When ``streamablehttp_client`` raises (e.g. the server only
speaks legacy SSE), ``_open_http_transport`` falls back to
``sse_client`` and the connection succeeds.
If the fallback were removed, any legacy SSE-only MCP server
would fail to connect. If ``streamablehttp_client`` is not
tried first, Streamable HTTP servers (e.g. Databricks MCP
gateways) would get the wrong transport.
A non-``/sse`` URL is used on purpose: an ``…/sse`` URL is routed
straight to the SSE client by ``_is_sse_endpoint`` and would bypass
Streamable HTTP entirely, so it would not exercise the fallback this
test guards.
"""
config = MCPServerConfig(
name="test-sse-fallback",
url="http://legacy-mcp.example.com/mcp",
headers={"Authorization": "Bearer tok"},
)
captured_sse_kwargs: dict[str, Any] = {}
mock_sse_ctx = AsyncMock()
mock_sse_ctx.__aenter__ = AsyncMock(
return_value=(MagicMock(), MagicMock()),
)
mock_sse_ctx.__aexit__ = AsyncMock(return_value=False)
def _capturing_sse(**kwargs: Any) -> AsyncMock:
"""
Fake ``sse_client`` that records kwargs.
:param kwargs: Keyword args including url, headers.
:returns: An async context manager yielding mock streams.
"""
captured_sse_kwargs.update(kwargs)
return mock_sse_ctx
mock_session = AsyncMock()
mock_session.initialize = AsyncMock()
mock_tools_result = MagicMock()
mock_tools_result.tools = [_make_mcp_tool_def("legacy_tool")]
mock_session.list_tools.return_value = mock_tools_result
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch(
"omnigent.tools.mcp.streamablehttp_client",
side_effect=RuntimeError("server returned text/html, not application/json"),
) as mock_streamable:
with patch(
"omnigent.tools.mcp.sse_client",
side_effect=_capturing_sse,
):
with patch(
"omnigent.tools.mcp.ClientSession",
return_value=mock_session,
):
conn = McpServerConnection(config=config)
tools = await conn.connect()
# Streamable HTTP was tried first and failed, so the fallback ran.
assert mock_streamable.called, (
"Streamable HTTP must be tried first for a non-/sse URL; if it was "
"skipped, the SSE fallback this test guards was never exercised"
)
# Fallback reached sse_client with the correct URL and headers.
assert captured_sse_kwargs["url"] == "http://legacy-mcp.example.com/mcp", (
"SSE fallback must receive the same URL as the original config"
)
assert captured_sse_kwargs["headers"] == {"Authorization": "Bearer tok"}, (
"SSE fallback must forward headers from the config"
)
# Tool discovery succeeded through the fallback path.
assert len(tools) == 1, (
"SSE fallback must discover tools; if 0, the session was not initialized"
)
assert tools[0].name == "legacy_tool"
await conn.close()
@pytest.mark.asyncio()
async def test_http_connect_discovers_tools() -> None:
"""
HTTP ``connect()`` discovers tools via ``list_tools()`` and
returns them.
"""
config = MCPServerConfig(
name="test-http-discovery",
url="http://localhost:9000/mcp",
)
tool_def = _make_mcp_tool_def("http_tool")
with _mock_http_transport([tool_def]) as captured:
conn = McpServerConnection(config=config)
tools = await conn.connect()
assert len(tools) == 1
assert tools[0].name == "http_tool"
captured.mock_session.initialize.assert_awaited_once()
captured.mock_session.list_tools.assert_awaited_once()
await conn.close()
@pytest.mark.asyncio()
async def test_http_call_tool_invokes_session() -> None:
"""
``call_tool()`` on an HTTP connection delegates to the
session's ``call_tool`` method and returns formatted results.
"""
config = MCPServerConfig(
name="test-http-invoke",
url="http://localhost:9000/mcp",
)
with _mock_http_transport([_make_mcp_tool_def("http_tool")]) as captured:
conn = McpServerConnection(config=config)
await conn.connect()
# Set up call_tool to return a mock result.
mock_result = MagicMock()
mock_result.content = [
TextContent(type="text", text="HTTP result"),
]
mock_result.isError = False
captured.mock_session.call_tool.return_value = mock_result
result = await conn.call_tool("http_tool", {"query": "test"})
assert result == "HTTP result"
captured.mock_session.call_tool.assert_awaited_once_with(
name="http_tool",
arguments={"query": "test"},
)
await conn.close()
@pytest.mark.asyncio()
async def test_http_reconnect_on_connection_error() -> None:
"""
HTTP ``call_tool()`` reconnects and retries on a connection
error.
"""
config = MCPServerConfig(
name="test-http-reconnect",
url="http://localhost:9000/mcp",
)
with _mock_http_transport([_make_mcp_tool_def("http_tool")]) as captured:
conn = McpServerConnection(config=config)
await conn.connect()
ok_result = MagicMock()
ok_result.content = [
TextContent(type="text", text="recovered via HTTP"),
]
ok_result.isError = False
captured.mock_session.call_tool.side_effect = [
ConnectionError("HTTP connection lost"),
ok_result,
]
with patch.object(conn, "_reconnect", new_callable=AsyncMock) as mock_reconnect:
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
result = await conn.call_tool("http_tool", {"query": "retry"})
assert result == "recovered via HTTP"
mock_reconnect.assert_awaited_once()
await conn.close()
@pytest.mark.asyncio()
async def test_http_connect_uses_cache() -> None:
"""
HTTP ``connect()`` uses the discovery cache when fresh,
skipping ``list_tools()`` while still opening a live session.
"""
config = MCPServerConfig(
name="test-http-cached",
url="http://localhost:9000/mcp",
)
tool_def = _make_mcp_tool_def("cached_http_tool")
_discovery_cache[_cache_key(config)] = [tool_def]
with _mock_http_transport() as captured:
conn = McpServerConnection(config=config)
tools = await conn.connect()
assert len(tools) == 1
assert tools[0].name == "cached_http_tool"
# Session opened (for invocation), but list_tools skipped.
captured.mock_session.initialize.assert_awaited_once()
captured.mock_session.list_tools.assert_not_awaited()
await conn.close()
# ── Circuit breaker ──────────────────────────────────────────
def test_circuit_breaker_allows_calls_when_closed() -> None:
"""
A fresh breaker in CLOSED state allows calls without raising.
"""
breaker = _CircuitBreaker(failure_threshold=3, cooldown_seconds=10.0)
# Should not raise.
breaker.pre_call("test-server")
def test_circuit_breaker_trips_after_threshold_failures() -> None:
"""
The breaker trips after ``failure_threshold`` consecutive
failures and blocks subsequent calls.
"""
breaker = _CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
for _ in range(3):
breaker.record_failure("test-server")
assert breaker.is_tripped is True
with pytest.raises(McpServerDisabledError) as exc_info:
breaker.pre_call("my-server")
assert exc_info.value.server_name == "my-server"
assert exc_info.value.consecutive_failures == 3
def test_circuit_breaker_does_not_trip_below_threshold() -> None:
"""
Fewer failures than the threshold do not trip the breaker.
"""
breaker = _CircuitBreaker(failure_threshold=5, cooldown_seconds=10.0)
for _ in range(4):
breaker.record_failure("test-server")
assert breaker.is_tripped is False
# Should not raise.
breaker.pre_call("test-server")
def test_circuit_breaker_resets_on_success() -> None:
"""
A successful call resets the failure counter and un-trips
the breaker.
"""
breaker = _CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
for _ in range(3):
breaker.record_failure("test-server")
assert breaker.is_tripped is True
breaker.record_success()
assert breaker.is_tripped is False
assert breaker.consecutive_failures == 0
# Should not raise after reset.
breaker.pre_call("test-server")
def test_circuit_breaker_half_open_after_cooldown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
After the cooldown period elapses, the breaker enters
half-open state and allows one probe call.
"""
import time as time_module
breaker = _CircuitBreaker(failure_threshold=2, cooldown_seconds=10.0)
breaker.record_failure("test-server")
breaker.record_failure("test-server")
assert breaker.is_tripped is True
# Advance time past the cooldown.
original_monotonic = time_module.monotonic
monkeypatch.setattr(
time_module,
"monotonic",
lambda: original_monotonic() + 15.0,
)
# Cooldown elapsed — half-open state allows one probe.
assert breaker.is_tripped is False
breaker.pre_call("test-server") # Should not raise.
def test_circuit_breaker_re_trips_on_half_open_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
If the half-open probe fails, the breaker re-trips.
"""
import time as time_module
breaker = _CircuitBreaker(failure_threshold=2, cooldown_seconds=10.0)
breaker.record_failure("test-server")
breaker.record_failure("test-server")
# Advance time past the cooldown.
original_monotonic = time_module.monotonic
monkeypatch.setattr(
time_module,
"monotonic",
lambda: original_monotonic() + 15.0,
)
# Half-open probe allowed.
breaker.pre_call("test-server")
# Probe fails — re-trip.
breaker.record_failure("test-server")
assert breaker.is_tripped is True
def test_circuit_breaker_cooldown_remaining_in_error() -> None:
"""
The ``McpServerDisabledError`` includes the approximate
cooldown remaining.
"""
breaker = _CircuitBreaker(failure_threshold=1, cooldown_seconds=30.0)
breaker.record_failure("test-server")
with pytest.raises(McpServerDisabledError) as exc_info:
breaker.pre_call("test-server")
# Cooldown just started, so remaining should be close to 30s.
assert exc_info.value.cooldown_remaining > 25.0
def test_circuit_breaker_failure_count_resets_on_success() -> None:
"""
Interspersed successes prevent the breaker from tripping.
"""
breaker = _CircuitBreaker(failure_threshold=3, cooldown_seconds=10.0)
breaker.record_failure("test-server")
breaker.record_failure("test-server")
# Success resets the counter.
breaker.record_success()
breaker.record_failure("test-server")
breaker.record_failure("test-server")
# Only 2 consecutive failures — not at threshold.
assert breaker.is_tripped is False
def test_circuit_breaker_trip_log_includes_server_name(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
When the breaker trips, the warning log message includes the
server name so operators can identify which MCP server failed.
:param caplog: Pytest fixture that captures log records.
"""
breaker = _CircuitBreaker(failure_threshold=2, cooldown_seconds=10.0)
with caplog.at_level(logging.WARNING, logger="omnigent.tools.mcp"):
breaker.record_failure("my-flaky-server")
breaker.record_failure("my-flaky-server")
assert breaker.is_tripped is True
# The trip log must name the specific server.
trip_messages = [r.message for r in caplog.records if "tripped" in r.message]
assert len(trip_messages) == 1, f"Expected 1 trip log, got {len(trip_messages)}"
assert "my-flaky-server" in trip_messages[0]
def test_circuit_breaker_default_constants() -> None:
"""
Module-level circuit breaker constants have expected values.
"""
assert _CIRCUIT_BREAKER_THRESHOLD == 5
assert _CIRCUIT_BREAKER_COOLDOWN_SECONDS == 30.0
@pytest.mark.asyncio
async def test_call_tool_trips_breaker_after_repeated_failures() -> None:
"""
``McpServerConnection.call_tool()`` records failures in the
circuit breaker. After ``failure_threshold`` exhausted
invocations, subsequent calls raise ``McpServerDisabledError``.
"""
config = _make_http_config()
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
# Override breaker threshold to 2 for a quick test.
conn._breaker = _CircuitBreaker(
failure_threshold=2,
cooldown_seconds=60.0,
)
mock_session.call_tool = AsyncMock(side_effect=EOFError("dead"))
with patch.object(conn, "_reconnect", new_callable=AsyncMock):
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
# First call: exhausts retries, records failure.
with pytest.raises(EOFError):
await conn.call_tool("my_tool", {"x": 1})
# Second call: exhausts retries, trips breaker.
with pytest.raises(EOFError):
await conn.call_tool("my_tool", {"x": 1})
# Third call: breaker is tripped, fails immediately.
with pytest.raises(McpServerDisabledError) as exc_info:
await conn.call_tool("my_tool", {"x": 1})
assert exc_info.value.server_name == config.name
assert exc_info.value.consecutive_failures == 2
await conn.close()
@pytest.mark.asyncio
async def test_call_tool_resets_breaker_on_success() -> None:
"""
A successful ``call_tool()`` resets the circuit breaker so
that prior failures don't accumulate across successes.
"""
config = _make_http_config()
with _mock_mcp_transport() as mock_session:
conn = McpServerConnection(config=config)
await conn.connect()
conn._breaker = _CircuitBreaker(
failure_threshold=2,
cooldown_seconds=60.0,
)
ok_result = MagicMock()
ok_result.content = [TextContent(type="text", text="ok")]
ok_result.isError = False
# 3 fails then 1 success (reconnect retries 3 per call).
mock_session.call_tool = AsyncMock(
side_effect=[
EOFError("dead"),
EOFError("dead"),
EOFError("dead"),
EOFError("dead"),
EOFError("dead"),
ok_result,
]
)
with patch.object(conn, "_reconnect", new_callable=AsyncMock):
with patch("omnigent.tools.mcp._sleep", new_callable=AsyncMock):
# First invocation: all 3 retries fail → failure (count=1).
with pytest.raises(EOFError):
await conn.call_tool("my_tool", {})
# Second invocation: 3rd retry succeeds → reset.
result = await conn.call_tool("my_tool", {})
assert result == "ok"
# Breaker should be reset — no accumulated failures.
assert conn._breaker.consecutive_failures == 0
await conn.close()
# ── Circuit breaker half-open atomic gate ─────────────────
def test_circuit_breaker_half_open_clears_tripped_at(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Entering half-open state clears ``_tripped_at`` so that
concurrent callers see CLOSED (not half-open) and don't
also enter the probe path.
:param monkeypatch: Pytest monkeypatch for time.monotonic.
"""
import time as time_module
breaker = _CircuitBreaker(failure_threshold=2, cooldown_seconds=10.0)
breaker.record_failure("test-server")
breaker.record_failure("test-server")
assert breaker.is_tripped is True
# Advance time past the cooldown.
original_monotonic = time_module.monotonic
monkeypatch.setattr(
time_module,
"monotonic",
lambda: original_monotonic() + 15.0,
)
# First pre_call enters half-open and clears the tripped state.
breaker.pre_call("test-server")
# Breaker should no longer report as tripped — a concurrent
# caller sees CLOSED, not half-open.
assert breaker.is_tripped is False
# Second pre_call should not raise (sees CLOSED state).
breaker.pre_call("test-server")
# ── EventLoopThread ──────────────────────────────────────
def _make_stdio_config(
*,
name: str = "stdio-server",
command: str = "fake-mcp",
args: list[str] | None = None,
env: dict[str, str] | None = None,
) -> MCPServerConfig:
"""
Create a stdio MCP server config for transport-branch tests.
:param name: Server name identifier.
:param command: Executable command, e.g. ``"npx"``.
:param args: Arguments to *command*, defaulting to ``[]``.
:param env: Environment overlay, defaulting to ``{}``.
:returns: An ``MCPServerConfig`` with ``transport="stdio"``.
"""
return MCPServerConfig(
name=name,
transport="stdio",
command=command,
args=list(args) if args is not None else [],
env=dict(env) if env is not None else {},
)
def test_open_stdio_transport_spawns_unwrapped() -> None:
"""
The stdio branch spawns the MCP subprocess directly — the
``StdioServerParameters`` command equals ``config.command``
and the args equal ``config.args``, with no ``srt`` prefix
inserted.
Step 7 of the harness contract migration removed the
``MCPServerConfig.sandbox`` field and the ``wrap_with_srt``
call that gated this spawn — srt's default policy blocked
outbound network and broke every useful MCP. This test pins
the post-step-7 behavior: the unwrap is not optional.
What breaks if this fails: a regression that re-introduces
``srt -c <command>`` would silently hang every stdio MCP
that needs outbound HTTPS (essentially all of them — Glean,
Slack, GitHub, UC, etc.).
"""
config = _make_stdio_config(
command="fake-mcp",
args=["--flag", "value"],
)
captured: dict[str, Any] = {}
def _capture_stdio_client(params: Any) -> Any:
"""Record params, then yield a mock transport."""
captured["params"] = params
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
mock_ctx.__aexit__ = AsyncMock(return_value=False)
return mock_ctx
conn = McpServerConnection(config=config)
with patch("omnigent.tools.mcp.stdio_client", side_effect=_capture_stdio_client):
with patch(
"omnigent.tools.mcp.ClientSession",
return_value=_mock_session(),
):
asyncio.run(conn.connect())
params = captured["params"]
# command + args pass through unchanged — any wrap insertion
# here means the srt path came back.
assert params.command == "fake-mcp"
assert params.args == ["--flag", "value"]
def test_open_stdio_transport_overlays_env_on_parent() -> None:
"""
``config.env`` is overlaid on ``os.environ`` so the spawned
subprocess inherits ``PATH``, ``HOME``, etc., plus the
MCP-specific overrides.
What breaks if this fails: an MCP subprocess that needs
``GITHUB_TOKEN=ghp_xyz`` but also needs ``PATH`` to resolve
its own binaries would lose PATH (getting only the caller's
env overlay) and fail at spawn with "fake-mcp: command not
found".
"""
config = _make_stdio_config(
command="fake-mcp",
env={"GITHUB_TOKEN": "ghp_xyz"},
)
captured: dict[str, Any] = {}
def _capture_stdio_client(params: Any) -> Any:
captured["params"] = params
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
mock_ctx.__aexit__ = AsyncMock(return_value=False)
return mock_ctx
conn = McpServerConnection(config=config)
with patch("omnigent.tools.mcp.stdio_client", side_effect=_capture_stdio_client):
with patch(
"omnigent.tools.mcp.ClientSession",
return_value=_mock_session(),
):
asyncio.run(conn.connect())
env = captured["params"].env
# Config overlay reached the subprocess
assert env["GITHUB_TOKEN"] == "ghp_xyz"
# Parent env merged — PATH is always in os.environ on any
# realistic test runner, so its presence confirms the dict
# union didn't wipe the inherited environment.
assert "PATH" in env
def test_open_stdio_transport_empty_env_inherits_fully() -> None:
"""
When ``config.env`` is empty, the transport passes
``env=None`` to :class:`StdioServerParameters` — which makes
the MCP SDK inherit the parent environment wholesale. An
explicit empty ``{}`` would wipe PATH etc. and break MCPs
that don't set their own.
What breaks if this fails: a common MCP YAML that omits
``env:`` (because the subprocess has no secret to inject)
would spawn with an empty environment and fail at PATH
resolution.
"""
config = _make_stdio_config(env={})
captured: dict[str, Any] = {}
def _capture_stdio_client(params: Any) -> Any:
captured["params"] = params
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
mock_ctx.__aexit__ = AsyncMock(return_value=False)
return mock_ctx
conn = McpServerConnection(config=config)
with patch("omnigent.tools.mcp.stdio_client", side_effect=_capture_stdio_client):
with patch(
"omnigent.tools.mcp.ClientSession",
return_value=_mock_session(),
):
asyncio.run(conn.connect())
# None tells stdio_client to inherit the parent env as-is —
# matches its documented behavior.
assert captured["params"].env is None
def _mock_session() -> AsyncMock:
"""
Build a mock ClientSession — minimum needed for the
stdio transport tests to reach connect() without exercising
the real MCP handshake.
"""
mock_session = AsyncMock()
mock_tools_result = MagicMock()
mock_tools_result.tools = []
mock_session.list_tools.return_value = mock_tools_result
mock_session.initialize = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
return mock_session
def _make_pool_mcp_tool(name: str) -> McpToolDef:
"""
Create a real MCP tool definition for session-pool tests.
:param name: Tool name, e.g. ``"search_tool"``.
:returns: A real :class:`mcp.types.Tool`.
"""
return McpToolDef(
name=name,
description=f"{name} description",
inputSchema={
"type": "object",
"properties": {},
},
)
# ── McpElicitationRequired & MRTR detection in _invoke_tool ────
def _make_input_required_result(
input_requests: dict[str, Any] | None = None,
request_state: str = "opaque_state",
) -> CallToolResult:
"""
Build a real ``CallToolResult`` with ``resultType: "input_required"``
via ``model_validate``, the same way the MCP SDK deserializes it
over the wire.
``CallToolResult``'s parent ``Result`` has ``extra="allow"``, so
extra fields like ``resultType``, ``inputRequests``, and
``requestState`` survive in ``model_extra``.
:param input_requests: The ``inputRequests`` map keyed by
server-assigned elicitation id.
:param request_state: The opaque ``requestState`` string.
:returns: A real ``CallToolResult`` with MRTR extras.
"""
if input_requests is None:
input_requests = {
"eid_1": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "approve?",
"requestedSchema": {
"type": "object",
"properties": {
"decision": {
"type": "string",
"oneOf": [
{"const": "allow"},
{"const": "deny"},
],
},
},
},
},
},
}
return CallToolResult.model_validate(
{
"content": [],
"isError": False,
"resultType": "input_required",
"inputRequests": input_requests,
"requestState": request_state,
}
)
def test_call_tool_result_model_extra_preserves_mrtr_fields() -> None:
"""
``CallToolResult.model_validate`` preserves MRTR fields
(``resultType``, ``inputRequests``, ``requestState``) in
``model_extra`` because the parent ``Result`` class has
``extra="allow"``.
If this fails: the MCP SDK changed its Pydantic model config
to ``extra="forbid"`` or ``extra="ignore"``, which would silently
drop MRTR data and break the entire elicitation flow.
"""
result = _make_input_required_result()
extras = result.model_extra or {}
# resultType signals that the server needs user input.
assert extras.get("resultType") == "input_required", (
"resultType must survive in model_extra; if missing, the MCP SDK "
"is dropping extra fields and MRTR detection will silently fail"
)
# inputRequests carries the elicitation payloads keyed by id.
assert "eid_1" in extras.get("inputRequests", {}), (
"inputRequests must include the server's elicitation entries; "
"if empty, the elicitation UI will show nothing to the user"
)
# requestState must round-trip verbatim on retry.
assert extras.get("requestState") == "opaque_state", (
"requestState must be preserved exactly; the server uses it "
"to correlate the retry with the original request"
)
@pytest.mark.asyncio
async def test_invoke_tool_raises_elicitation_on_input_required() -> None:
"""
``_invoke_tool`` raises ``McpElicitationRequired`` when the
MCP session returns a ``CallToolResult`` with
``resultType == "input_required"`` in ``model_extra``.
If this fails: the MRTR detection in ``_invoke_tool`` is broken
and the runner will treat an ``InputRequiredResult`` as a normal
(empty) tool result, silently skipping the elicitation flow.
"""
config = _make_http_config()
mrtr_result = _make_input_required_result(
input_requests={
"eid_abc": {
"method": "elicitation/create",
"params": {"mode": "form", "message": "confirm deploy?"},
},
},
request_state="state_xyz",
)
with _mock_mcp_transport() as mock_session:
# Stub session.call_tool to return the MRTR result.
mock_session.call_tool.return_value = mrtr_result
conn = McpServerConnection(config=config)
await conn.connect()
with pytest.raises(McpElicitationRequired) as exc_info:
await conn._invoke_tool("deploy_tool", {"env": "prod"})
exc = exc_info.value
# input_requests carries the full elicitation payloads.
assert "eid_abc" in exc.input_requests, (
"input_requests must include the elicitation id from the server; "
"if missing, the Omnigent server can't surface the elicitation to the user"
)
# request_state must be echoed back verbatim on retry.
assert exc.request_state == "state_xyz", (
"request_state must match the server's opaque value; "
"if wrong, the retry will be rejected by the server"
)
# tool_name and arguments are preserved for the retry call.
assert exc.tool_name == "deploy_tool", (
"tool_name must be preserved so the retry knows which tool to call"
)
assert exc.arguments == {"env": "prod"}, (
"arguments must be preserved so the retry uses the same parameters"
)
await conn.close()
@pytest.mark.asyncio
async def test_invoke_tool_returns_normally_without_mrtr() -> None:
"""
``_invoke_tool`` returns the formatted result string when
``model_extra`` does NOT contain ``resultType == "input_required"``.
If this fails: normal (non-MRTR) tool calls are broken — the
function is raising ``McpElicitationRequired`` when it shouldn't.
"""
config = _make_http_config()
# Normal result — no extra fields triggering MRTR.
normal_result = CallToolResult.model_validate(
{
"content": [{"type": "text", "text": "tool output here"}],
"isError": False,
}
)
with _mock_mcp_transport() as mock_session:
mock_session.call_tool.return_value = normal_result
conn = McpServerConnection(config=config)
await conn.connect()
result = await conn._invoke_tool("normal_tool", {"x": 1})
# Normal path: formatted text returned, no exception.
assert result == "tool output here", (
"Normal tool results must be returned as formatted text; "
"if McpElicitationRequired was raised instead, the MRTR "
"detection has a false positive"
)
await conn.close()
@pytest.mark.asyncio
async def test_call_tool_with_elicitation_returns_result() -> None:
"""
``call_tool_with_elicitation`` sends a retry with
``inputResponses`` and ``requestState`` and returns the
formatted result when the server responds normally.
If this fails: the elicitation retry path is broken — the
runner can't complete the tool call after the user approves.
"""
config = _make_http_config()
# The retry returns a normal result.
retry_result = CallToolResult.model_validate(
{
"content": [{"type": "text", "text": "deploy succeeded"}],
"isError": False,
}
)
with _mock_mcp_transport() as mock_session:
mock_session.send_request = AsyncMock(return_value=retry_result)
conn = McpServerConnection(config=config)
await conn.connect()
result = await conn.call_tool_with_elicitation(
name="deploy_tool",
arguments={"env": "prod"},
input_responses={"eid_1": {"action": "accept", "content": {"decision": "allow"}}},
request_state="state_xyz",
)
# The retry result is formatted and returned.
assert result == "deploy succeeded", (
"Elicitation retry must return the formatted tool result; "
"if empty or wrong, the retry path lost the server's response"
)
await conn.close()
@pytest.mark.asyncio
async def test_call_tool_with_elicitation_raises_on_second_mrtr() -> None:
"""
``call_tool_with_elicitation`` raises ``McpElicitationRequired``
when the retry itself returns another ``InputRequiredResult``
(multi-round MRTR).
If this fails: multi-round elicitation is broken — the second
elicitation round silently returns an empty result instead of
surfacing the next approval request.
"""
config = _make_http_config()
# The retry returns yet another InputRequiredResult.
second_mrtr = _make_input_required_result(
input_requests={
"eid_2": {"method": "elicitation/create", "params": {"message": "round 2"}}
},
request_state="state_round2",
)
with _mock_mcp_transport() as mock_session:
mock_session.send_request = AsyncMock(return_value=second_mrtr)
conn = McpServerConnection(config=config)
await conn.connect()
with pytest.raises(McpElicitationRequired) as exc_info:
await conn.call_tool_with_elicitation(
name="deploy_tool",
arguments={"env": "prod"},
input_responses={"eid_1": {"action": "accept", "content": {}}},
request_state="state_xyz",
)
exc = exc_info.value
# Second round's elicitation data must surface.
assert "eid_2" in exc.input_requests, (
"Multi-round MRTR must surface the second elicitation's requests; "
"if missing, the Omnigent server can't show the next approval form"
)
assert exc.request_state == "state_round2", (
"Multi-round MRTR must carry the new requestState for the next retry"
)
await conn.close()
# ── _is_sse_endpoint / legacy-SSE routing ────────────────
def test_is_sse_endpoint_detects_sse_paths() -> None:
"""
URLs whose path ends in a ``/sse`` segment are legacy-SSE
endpoints (e.g. crawl4ai's ``/mcp/sse``). The Streamable HTTP
client hangs in teardown against such a server, so the transport
router must detect these and use the SSE client directly.
"""
from omnigent.tools.mcp import _is_sse_endpoint
assert _is_sse_endpoint("http://h:1/mcp/sse")
assert _is_sse_endpoint("http://h:1/mcp/sse/") # trailing slash
assert _is_sse_endpoint("http://h:1/sse")
assert not _is_sse_endpoint("http://h:1/mcp")
assert not _is_sse_endpoint("http://h:1/")
assert not _is_sse_endpoint("http://h:1")
assert not _is_sse_endpoint("http://h:1/mcp/sse-events") # not a /sse segment
@pytest.mark.asyncio()
async def test_open_http_transport_routes_sse_url_straight_to_sse() -> None:
"""
An ``…/sse`` URL goes directly to the SSE transport.
The Streamable HTTP client hangs in teardown against an SSE-only
server (crawl4ai), so it must be SKIPPED — not merely
tried-then-fallen-back-from, because the hang prevents the
fallback from ever running.
"""
from contextlib import AsyncExitStack
conn = McpServerConnection(config=MCPServerConfig(name="c", url="http://h:1/mcp/sse"))
calls: list[str] = []
async def fake_sse(stack, timeout, headers):
calls.append("sse")
return ("r", "w")
async def fake_streamable(stack, timeout, headers):
calls.append("streamable")
return ("r", "w")
conn._open_sse_transport = fake_sse # type: ignore[method-assign]
conn._open_streamable_http_transport = fake_streamable # type: ignore[method-assign]
async with AsyncExitStack() as stack:
await conn._open_http_transport(stack)
assert calls == ["sse"], "…/sse URL must skip Streamable HTTP entirely"
@pytest.mark.asyncio()
async def test_open_http_transport_uses_streamable_for_non_sse_url() -> None:
"""
A plain HTTP MCP URL still tries Streamable HTTP first (with the
existing SSE fallback on failure) — the routing change must not
regress modern Streamable-HTTP servers.
"""
from contextlib import AsyncExitStack
conn = McpServerConnection(config=MCPServerConfig(name="c", url="http://h:1/mcp"))
calls: list[str] = []
async def fake_sse(stack, timeout, headers):
calls.append("sse")
return ("r", "w")
async def fake_streamable(stack, timeout, headers):
calls.append("streamable")
return ("r", "w")
conn._open_sse_transport = fake_sse # type: ignore[method-assign]
conn._open_streamable_http_transport = fake_streamable # type: ignore[method-assign]
async with AsyncExitStack() as stack:
await conn._open_http_transport(stack)
assert calls == ["streamable"], "plain URL must try Streamable HTTP first"