|
21 | 21 | from google.adk.telemetry import _metrics |
22 | 22 | from google.adk.telemetry import tracing |
23 | 23 | from google.adk.tools import FunctionTool |
| 24 | +from google.adk.tools.base_tool import BaseTool |
| 25 | +from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams |
| 26 | +from google.adk.tools.mcp_tool.mcp_toolset import McpToolset |
| 27 | +from google.adk.tools.tool_context import ToolContext |
24 | 28 | from google.genai import types |
25 | 29 | from google.genai.types import Part |
| 30 | +from mcp import ClientSession as McpClientSession |
| 31 | +from mcp import StdioServerParameters |
| 32 | +from mcp.types import ListToolsResult |
| 33 | +from mcp.types import PaginatedRequestParams |
| 34 | +from mcp.types import Tool as McpTool |
| 35 | +from opentelemetry import trace |
26 | 36 | from opentelemetry.instrumentation.google_genai import GoogleGenAiSdkInstrumentor |
27 | 37 | from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter |
28 | 38 | from opentelemetry.sdk.metrics import MeterProvider |
29 | 39 | from opentelemetry.sdk.metrics.export import InMemoryMetricReader |
30 | 40 | from opentelemetry.sdk.metrics.export import Metric |
31 | 41 | from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter |
32 | 42 | import pytest |
| 43 | +from typing_extensions import override |
33 | 44 |
|
34 | 45 | from ..testing_utils import InMemoryRunner |
35 | 46 | from ..testing_utils import MockModel |
| 47 | +from ..testing_utils import TestInMemoryRunner |
36 | 48 | from .functional_test_cases import ALL_CASES |
| 49 | +from .functional_test_cases import EXPECTED_EXPERIMENTAL_SPAN_AND_EVENT_WITH_MCP |
37 | 50 | from .functional_test_helpers import aclosing_wrapping_assertions |
38 | 51 | from .functional_test_helpers import AGENT_NAME |
39 | 52 | from .functional_test_helpers import build_test_agent |
40 | 53 | from .functional_test_helpers import build_test_runner |
| 54 | +from .functional_test_helpers import CAPTURE_CONTENT |
| 55 | +from .functional_test_helpers import EXPERIMENTAL_OPT_IN |
41 | 56 | from .functional_test_helpers import FunctionalTestCase |
42 | 57 | from .functional_test_helpers import install_telemetry |
| 58 | +from .functional_test_helpers import OTEL_OPT_IN |
43 | 59 | from .functional_test_helpers import run_agent_scenario |
44 | 60 | from .functional_test_helpers import SpanDigest |
45 | 61 | from .functional_test_helpers import TOOL_NAME |
@@ -420,6 +436,160 @@ async def failing_tool(): |
420 | 436 | ), |
421 | 437 | ] |
422 | 438 |
|
423 | | - got.sort(key=lambda p: p.attributes.get("gen_ai.tool.name", "")) |
424 | | - want.sort(key=lambda p: p.attributes.get("gen_ai.tool.name", "")) |
| 439 | + got.sort(key=lambda p: str(p.attributes.get("gen_ai.tool.name", ""))) |
| 440 | + want.sort(key=lambda p: str(p.attributes.get("gen_ai.tool.name", ""))) |
425 | 441 | assert got == want |
| 442 | + |
| 443 | + |
| 444 | +# --------------------------------------------------------------------------- |
| 445 | +# MCP integration: telemetry adds zero ``list_tools()`` calls of its own. |
| 446 | +# |
| 447 | +# The standard ADK ↔ MCP integration path is: |
| 448 | +# |
| 449 | +# Agent(tools=[McpToolset(...)]) |
| 450 | +# → McpToolset.get_tools() ─ calls list_tools() ONCE, caches MCPTool list |
| 451 | +# → BaseLlmFlow loop calls each MCPTool.process_llm_request, which |
| 452 | +# materializes the tool's FunctionDeclaration into |
| 453 | +# llm_request.config.tools. |
| 454 | +# |
| 455 | +# By the time the experimental semconv builder reads |
| 456 | +# ``llm_request.config.tools``, MCP tools are ALREADY ``types.Tool`` |
| 457 | +# entries with ``function_declarations``. Because the builder is fully |
| 458 | +# synchronous (it never calls ``list_tools()`` itself), the MCP server is |
| 459 | +# queried EXACTLY ONCE per agent invocation regardless of which semconv |
| 460 | +# (or capture mode) is active. These tests pin that contract AND verify |
| 461 | +# the resolved tool definitions surface intact in the experimental |
| 462 | +# telemetry. |
| 463 | +# |
| 464 | +# A ``_FakeMcpSession`` substitutes the live ``McpClientSession`` so the |
| 465 | +# test doesn't need a running MCP server. ``McpToolset.create_session`` |
| 466 | +# is patched to hand it out instead of dialing ``StdioServerParameters``. |
| 467 | +# --------------------------------------------------------------------------- |
| 468 | + |
| 469 | + |
| 470 | +class _FakeMcpSession(McpClientSession): |
| 471 | + """Minimal ``McpClientSession`` stand-in with a counted ``list_tools()``. |
| 472 | +
|
| 473 | + Subclasses ``McpClientSession`` (and skips its real ``__init__``) so that |
| 474 | + every ``isinstance(x, McpClientSession)`` check in ADK and in the MCP |
| 475 | + Python client passes, without needing to wire up the underlying anyio |
| 476 | + memory streams + peer process. |
| 477 | + """ |
| 478 | + |
| 479 | + def __init__( # pyright: ignore[reportMissingSuperCall] |
| 480 | + self, *, tools: list[McpTool] |
| 481 | + ) -> None: |
| 482 | + # Deliberately skip ``McpClientSession.__init__``: the real one wants |
| 483 | + # live anyio streams + a peer process. ``isinstance`` checks still |
| 484 | + # succeed, which is all ADK's MCP plumbing requires. |
| 485 | + self._tools: list[McpTool] = tools |
| 486 | + self.list_tools_call_count: int = 0 |
| 487 | + |
| 488 | + @override |
| 489 | + async def list_tools( |
| 490 | + self, |
| 491 | + cursor: str | None = None, |
| 492 | + *, |
| 493 | + params: PaginatedRequestParams | None = None, |
| 494 | + ) -> ListToolsResult: |
| 495 | + self.list_tools_call_count += 1 |
| 496 | + return ListToolsResult(tools=list(self._tools)) |
| 497 | + |
| 498 | + |
| 499 | +def _make_fake_mcp_toolset( |
| 500 | + monkeypatch: pytest.MonkeyPatch, fake_session: _FakeMcpSession |
| 501 | +) -> McpToolset: |
| 502 | + """Returns an ``McpToolset`` whose session manager hands out ``fake_session``. |
| 503 | +
|
| 504 | + Patches the toolset's ``MCPSessionManager`` so: |
| 505 | + * ``create_session`` returns the fake (no socket / subprocess). |
| 506 | + * ``close`` is a no-op (the fake holds no resources). |
| 507 | +
|
| 508 | + Connection params are nominally a stdio command but never actually |
| 509 | + invoked because ``create_session`` is overridden. |
| 510 | + """ |
| 511 | + toolset = McpToolset( |
| 512 | + connection_params=StdioConnectionParams( |
| 513 | + server_params=StdioServerParameters(command="unused-by-test"), |
| 514 | + ) |
| 515 | + ) |
| 516 | + |
| 517 | + async def _create_session(*_args, **_kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] |
| 518 | + return fake_session |
| 519 | + |
| 520 | + async def _close(*_args, **_kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] |
| 521 | + return None |
| 522 | + |
| 523 | + monkeypatch.setattr( |
| 524 | + toolset._mcp_session_manager, "create_session", _create_session # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType] |
| 525 | + ) |
| 526 | + monkeypatch.setattr(toolset._mcp_session_manager, "close", _close) # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType] |
| 527 | + return toolset |
| 528 | + |
| 529 | + |
| 530 | +def _build_mcp_test_runner(toolset: McpToolset) -> TestInMemoryRunner: |
| 531 | + """Builds a single-turn agent runner whose only tool source is ``toolset``. |
| 532 | +
|
| 533 | + Single-turn (one ``Part.from_text`` response) so the assertion on |
| 534 | + ``list_tools_call_count`` is unambiguous: exactly one agent invocation |
| 535 | + is performed. |
| 536 | + """ |
| 537 | + mock_model = MockModel.create( |
| 538 | + responses=[Part.from_text(text="text response")] |
| 539 | + ) |
| 540 | + test_agent = Agent( |
| 541 | + name="some_root_agent", |
| 542 | + description="A sample root agent.", |
| 543 | + instruction="you are helpful", |
| 544 | + model=mock_model, |
| 545 | + tools=[toolset], |
| 546 | + ) |
| 547 | + return TestInMemoryRunner(node=test_agent) |
| 548 | + |
| 549 | + |
| 550 | +@pytest.mark.asyncio |
| 551 | +async def test_mcp_list_tools_called_once_under_experimental_semconv( |
| 552 | + monkeypatch: pytest.MonkeyPatch, |
| 553 | +) -> None: |
| 554 | + """Experimental semconv: exactly one ``list_tools()`` call per invocation. |
| 555 | +
|
| 556 | + By the time the experimental semconv builder inspects |
| 557 | + ``llm_request.config.tools``, ``McpToolset`` has already materialized |
| 558 | + each MCP tool into a ``FunctionDeclaration`` — so the synchronous |
| 559 | + builder never has to (and never does) talk to the MCP server. The |
| 560 | + MCP-resolved tool definition still surfaces in the experimental |
| 561 | + telemetry intact, sourced from the ``FunctionDeclaration`` rather than |
| 562 | + from a fresh ``list_tools()`` call. |
| 563 | + """ |
| 564 | + monkeypatch.setenv(OTEL_OPT_IN, EXPERIMENTAL_OPT_IN) |
| 565 | + monkeypatch.setenv(CAPTURE_CONTENT, "span_and_event") |
| 566 | + monkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "false") |
| 567 | + |
| 568 | + span_exporter = InMemorySpanExporter() |
| 569 | + log_exporter = InMemoryLogRecordExporter() |
| 570 | + install_telemetry(monkeypatch, span_exporter, log_exporter) |
| 571 | + |
| 572 | + fake_session = _FakeMcpSession( |
| 573 | + tools=[ |
| 574 | + McpTool( |
| 575 | + name="mcp_echo", |
| 576 | + description="Echoes back its input.", |
| 577 | + inputSchema={ |
| 578 | + "type": "object", |
| 579 | + "properties": {"text": {"type": "string"}}, |
| 580 | + "required": ["text"], |
| 581 | + }, |
| 582 | + ) |
| 583 | + ] |
| 584 | + ) |
| 585 | + toolset = _make_fake_mcp_toolset(monkeypatch, fake_session) |
| 586 | + |
| 587 | + await run_agent_scenario(_build_mcp_test_runner(toolset)) |
| 588 | + |
| 589 | + assert fake_session.list_tools_call_count == 1 |
| 590 | + |
| 591 | + digest = SpanDigest.build( |
| 592 | + span_exporter.get_finished_spans(), |
| 593 | + log_exporter.get_finished_logs(), |
| 594 | + ) |
| 595 | + assert digest == EXPECTED_EXPERIMENTAL_SPAN_AND_EVENT_WITH_MCP |
0 commit comments