Source code for edri.integrations.pydantic_ai.mcp

"""Auditing the one MCP operation Pydantic AI exposes a public seam for.

Of the MCP protocol operations an agent performs, exactly one can be observed
without reaching into private internals on the supported dependency floor:
``tools/call``, through ``MCPToolset(process_tool_call=...)``. ``initialize``,
``tools/list``, ``resources/*`` and ``prompts/*`` round-trip correctly but offer
no public observation point, so this module makes no claim about them -- and
``MCPToolset.direct_call_tool()`` bypasses the seam entirely, so an application
calling a toolset directly is outside the observed set.

Installation is explicit, in the agent's own ``build_agent()``::

    from pydantic_ai.mcp import MCPToolset
    from edri.integrations.pydantic_ai import audited_tool_calls

    class WeatherAgent(AgentBase[AgentDeps, str]):
        def build_agent(self):
            toolset = MCPToolset(
                client, id="weather",
                process_tool_call=audited_tool_calls("weather"),
            )
            return Agent(self.model_name, deps_type=AgentDeps, toolsets=[toolset])

Explicit because the toolset is the application's object: EDRI does not reach
into an agent's toolsets to instrument them, and an integration that silently
replaced a user's own ``process_tool_call`` would be worse than one that asks
for a line of wiring.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from uuid import uuid4

from edri.integrations.pydantic_ai.lifecycle import (
    _current_run,
    _current_tool_action,
    _observed,
)
from edri.integrations.pydantic_ai.observability import (
    AgentActionNamespace,
    AgentActionOperation,
    _Capture,
)

if TYPE_CHECKING:  # Type-only: importing it at runtime would require the extra.
    from pydantic_ai.mcp import ProcessToolCallback

__all__ = ["audited_tool_calls"]


[docs] def audited_tool_calls(server: str | None = None) -> ProcessToolCallback: """A ``process_tool_call`` callback that publishes one action per call. ``server`` labels the server in the action stream. The seam is handed only the run context, the transport callable, a tool name and its arguments -- never the connection -- so the label cannot be discovered here; pass the same name the toolset carries as its ``id``. The returned callback is transparent. It awaits the real call, returns its result unchanged and re-raises its exceptions unchanged, including the ``ModelRetry`` that a server-side failure arrives as. A call made while no observed run is in progress -- a shared connection's own traffic -- is passed straight through: its attribution is unknown, and EDRI publishes nothing rather than guessing a run for it. The return type is the real ``ProcessToolCallback``, imported only under ``TYPE_CHECKING``: ``pydantic_ai.mcp`` raises ``ImportError`` unless the ``mcp`` extra is installed, and this package must import without it. """ async def audited( ctx: Any, call_tool: Any, name: str, args: dict[str, Any] ) -> Any: run = _current_run() if run is None: return await call_tool(name, args) policy = run.policy return await _observed( run.emitter, lambda: call_tool(name, args), shared={ "action_id": uuid4(), "namespace": AgentActionNamespace.MCP, "operation": AgentActionOperation.MCP_TOOLS_CALL, # The tool execution that made this call, when there is one: an # MCP call and the agent tool containing it are distinct # operations, and an inspector needs to see both. "parent_action_id": ( _current_tool_action(run.state.root_action_id) or run.state.root_action_id ), "root_action_id": run.state.root_action_id, "run_id": run.state.run_id, "conversation_id": run.state.conversation_id, "name": name, "metadata": {"server": server}, }, request=_Capture(args, policy.include_mcp_requests), terminal=lambda result: { "response_data": _Capture(result, policy.include_mcp_responses) }, ) return audited