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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from google.adk.tools.mcp_tool import MCPTool, MCPToolset
from google.adk.tools.tool_context import ToolContext

from solace_ai_connector.common.observability import MonitorLatency

from ...common.observability import McpRemoteMonitor
from ...common.utils.embeds import (
EARLY_EMBED_TYPES,
EMBED_DELIMITER_OPEN,
Expand Down Expand Up @@ -403,7 +406,8 @@ async def _execute_tool_with_audit_logs(self, tool_call, tool_context):
)
start_time = time.perf_counter()
try:
result = await tool_call()
with MonitorLatency(McpRemoteMonitor.call_tool()):
result = await tool_call()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it doesn't look like we can distinguish the tool_name being invoked here - unless there is just one ? - can you please confirm no matter what tool is being passed here we always hit same endpoint - if this is the case we could live with this solution

duration_ms = (time.perf_counter() - start_time) * 1000
_log_mcp_tool_success(
tool_context.session.user_id,
Expand Down
37 changes: 36 additions & 1 deletion src/solace_agent_mesh/common/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
to prevent accidental metric explosion.
"""

from solace_ai_connector.common.observability.monitors.operation import OperationMonitor
from solace_ai_connector.common.observability.monitors.base import MonitorInstance
from solace_ai_connector.common.observability.monitors.operation import OperationMonitor
from solace_ai_connector.common.observability.monitors.remote import (
RemoteRequestMonitor,
)


class AgentMonitor(OperationMonitor):
Expand Down Expand Up @@ -81,4 +84,36 @@ def create(cls, name: str) -> MonitorInstance:
component_type="tool",
component_name=name,
operation="execute"
)


class McpRemoteMonitor(RemoteRequestMonitor):
"""Monitor for outbound MCP server calls.

Maps to: outbound.request.duration histogram
Labels: service.peer.name="mcp_server", operation.name, error.type
"""

@staticmethod
def parse_error(exc: Exception) -> str:
"""Map MCP/httpx-specific exceptions to error categories."""
try:
import httpx

if isinstance(exc, httpx.TimeoutException):
return "timeout"
except ImportError:
pass
return RemoteRequestMonitor.parse_error(exc)

@classmethod
def call_tool(cls) -> MonitorInstance:
"""Create monitor instance for MCP tool call execution."""
return MonitorInstance(
monitor_type=cls.monitor_type,
labels={
"service.peer.name": "mcp_server",
"operation.name": "call_tool",
},
error_parser=cls.parse_error,
)
108 changes: 108 additions & 0 deletions tests/unit/agent/adk/test_mcp_observability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Tests for MCP connector observability instrumentation.

Verifies outbound.request.duration metrics are recorded with correct labels
when MCP tool calls are executed through EmbedResolvingMCPTool.
"""

import pytest
from unittest.mock import Mock, patch

from solace_agent_mesh.agent.adk.embed_resolving_mcp_toolset import (
EmbedResolvingMCPTool,
)


def _make_embed_tool():
mock_original_tool = Mock()
mock_original_tool.name = "test_mcp_tool"
mock_original_tool._mcp_tool = Mock()
mock_original_tool._mcp_tool.name = "test_mcp_tool"
mock_original_tool._mcp_tool.auth_scheme = None
mock_original_tool._mcp_tool.auth_credential = None
mock_original_tool._mcp_session_manager = Mock()
return EmbedResolvingMCPTool(
original_mcp_tool=mock_original_tool,
tool_config=None,
credential_manager=None,
)


def _make_tool_context():
mock_session = Mock()
mock_session.user_id = "user123"
mock_session.id = "session456"
mock_tool_context = Mock()
mock_tool_context.session = mock_session
mock_tool_context.agent_name = "test-agent"
return mock_tool_context


def _capture_metrics():
recorded = []

def capture_record(duration, labels):
recorded.append({"duration": duration, "labels": dict(labels)})

mock_recorder = Mock()
mock_recorder.record = capture_record
mock_registry = Mock()
mock_registry.get_recorder.return_value = mock_recorder
return recorded, mock_registry


def _find_metric(recorded, **expected_labels):
for m in recorded:
if all(m["labels"].get(k) == v for k, v in expected_labels.items()):
return m
return None


@pytest.mark.asyncio
class TestMcpObservability:

async def test_successful_call_records_metric(self):
embed_tool = _make_embed_tool()
tool_context = _make_tool_context()

async def mock_tool_call():
return {"result": "success"}

recorded, mock_registry = _capture_metrics()
with patch(
"solace_ai_connector.common.observability.api.MetricRegistry"
) as mock_reg_cls, patch(
"solace_agent_mesh.agent.adk.embed_resolving_mcp_toolset._log_mcp_tool_call"
), patch(
"solace_agent_mesh.agent.adk.embed_resolving_mcp_toolset._log_mcp_tool_success"
):
mock_reg_cls.get_instance.return_value = mock_registry
result = await embed_tool._execute_tool_with_audit_logs(mock_tool_call, tool_context)

assert result == {"result": "success"}
metric = _find_metric(recorded, **{"service.peer.name": "mcp_server", "operation.name": "call_tool"})
assert metric is not None, f"Expected metric not found in {recorded}"
assert metric["labels"]["error.type"] == "none"
assert metric["duration"] >= 0

async def test_failed_call_records_error_metric(self):
embed_tool = _make_embed_tool()
tool_context = _make_tool_context()

async def mock_tool_call():
raise ValueError("MCP server error")

recorded, mock_registry = _capture_metrics()
with patch(
"solace_ai_connector.common.observability.api.MetricRegistry"
) as mock_reg_cls, patch(
"solace_agent_mesh.agent.adk.embed_resolving_mcp_toolset._log_mcp_tool_call"
), patch(
"solace_agent_mesh.agent.adk.embed_resolving_mcp_toolset._log_mcp_tool_failure"
):
mock_reg_cls.get_instance.return_value = mock_registry
with pytest.raises(ValueError):
await embed_tool._execute_tool_with_audit_logs(mock_tool_call, tool_context)

metric = _find_metric(recorded, **{"service.peer.name": "mcp_server", "operation.name": "call_tool"})
assert metric is not None, f"Expected metric not found in {recorded}"
assert metric["labels"]["error.type"] == "ValueError"
Loading