diff --git a/.changelog/agent-instrument.md b/.changelog/agent-instrument.md new file mode 100644 index 00000000..42cfd6e3 --- /dev/null +++ b/.changelog/agent-instrument.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added scoped instrumentation for payment-aware sync/async httpx and MCP client calls. diff --git a/src/mpp/agent.py b/src/mpp/agent.py new file mode 100644 index 00000000..ea5b3cc8 --- /dev/null +++ b/src/mpp/agent.py @@ -0,0 +1,287 @@ +"""Scoped instrumentation for payment-aware HTTP and MCP calls.""" + +from __future__ import annotations + +import asyncio +import threading +from contextvars import ContextVar +from dataclasses import dataclass +from types import MethodType +from typing import Any, Literal + +import httpx + +from mpp.runtime import ( + PaymentRuntime, + mcp_payment_flow_active, + payment_flow_active, + payment_flow_active_in_process, +) + + +@dataclass(eq=False, slots=True) +class _Binding: + runtime: PaymentRuntime + httpx: bool + mcp: bool + active: bool = True + + +_bindings: ContextVar[tuple[_Binding, ...] | None] = ContextVar( + "mpp_instrumentation_bindings", + default=None, +) +_httpx_active: ContextVar[bool] = ContextVar("mpp_httpx_instrumentation_active", default=False) +_mcp_active: ContextVar[bool] = ContextVar("mpp_mcp_instrumentation_active", default=False) + + +@dataclass(slots=True) +class InstrumentationHandle: + """Handle returned by :func:`instrument`.""" + + runtime: PaymentRuntime + _binding: _Binding + + def disable(self) -> None: + """Disable this binding and restore unused process patches safely.""" + binding = self._binding + with _state.lock: + if not binding.active: + return + binding.active = False + _state.bindings = [item for item in _state.bindings if item is not binding] + _restore_unused_patches() + + local = _bindings.get() + if local is not None: + _bindings.set(tuple(item for item in local if item is not binding)) + + def __enter__(self) -> InstrumentationHandle: + return self + + def __exit__(self, *_args: Any) -> None: + self.disable() + + +def instrument( + runtime: PaymentRuntime, + *, + httpx: bool = True, + mcp: Literal["auto"] | bool = "auto", +) -> InstrumentationHandle: + """Make common Python HTTP and MCP client boundaries payment-aware. + + Selection is context-local when instrumentation is installed in an async + task or request context. A bare thread uses the process fallback only when + exactly one runtime is active, which supports harness worker threads without + choosing between multiple wallets. + """ + client_session = _resolve_mcp_client(required=mcp is True) if mcp is not False else None + binding = _Binding(runtime=runtime, httpx=httpx, mcp=client_session is not None) + + with _state.lock: + try: + if httpx: + _install_httpx_patches() + if client_session is not None: + _install_mcp_patch(client_session) + except BaseException: + _restore_unused_patches() + raise + _state.bindings.append(binding) + + local = _bindings.get() + _bindings.set((*(() if local is None else local), binding)) + return InstrumentationHandle(runtime=runtime, _binding=binding) + + +class _InstrumentationState: + def __init__(self) -> None: + self.lock = threading.RLock() + self.bindings: list[_Binding] = [] + self.original_sync_send: Any | None = None + self.sync_send_patch: Any | None = None + self.original_async_send: Any | None = None + self.async_send_patch: Any | None = None + self.original_mcp_call_tool: Any | None = None + self.mcp_call_tool_patch: Any | None = None + self.mcp_client_session: Any | None = None + + +_state = _InstrumentationState() + + +def _select_runtime(protocol: Literal["httpx", "mcp"]) -> PaymentRuntime | None: + local = _bindings.get() + if local is not None: + for binding in reversed(local): + if binding.active and getattr(binding, protocol): + return binding.runtime + return None + + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + return None + + if payment_flow_active_in_process(): + return None + + with _state.lock: + runtimes: list[PaymentRuntime] = [] + for binding in _state.bindings: + if not binding.active or not getattr(binding, protocol): + continue + if all(runtime is not binding.runtime for runtime in runtimes): + runtimes.append(binding.runtime) + return runtimes[0] if len(runtimes) == 1 else None + + +def _install_httpx_patches() -> None: + if _state.original_sync_send is None: + original_sync_send = httpx.Client.send + + def sync_send( + self: httpx.Client, + request: httpx.Request, + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + if ( + getattr(self, "_mpp_payment_wrapped", False) + or payment_flow_active() + or _httpx_active.get() + ): + return original_sync_send(self, request, *args, **kwargs) + runtime = getattr(self, "_mpp_payment_runtime", None) or _select_runtime("httpx") + if runtime is None: + return original_sync_send(self, request, *args, **kwargs) + token = _httpx_active.set(True) + try: + return runtime.send_httpx_sync( + MethodType(original_sync_send, self), + request, + *args, + **kwargs, + ) + finally: + _httpx_active.reset(token) + + _state.original_sync_send = original_sync_send + _state.sync_send_patch = sync_send + httpx.Client.send = sync_send # type: ignore[method-assign] + + if _state.original_async_send is None: + original_async_send = httpx.AsyncClient.send + + async def async_send( + self: httpx.AsyncClient, + request: httpx.Request, + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + if ( + getattr(self, "_mpp_payment_wrapped", False) + or payment_flow_active() + or _httpx_active.get() + ): + return await original_async_send(self, request, *args, **kwargs) + runtime = getattr(self, "_mpp_payment_runtime", None) or _select_runtime("httpx") + if runtime is None: + return await original_async_send(self, request, *args, **kwargs) + token = _httpx_active.set(True) + try: + return await runtime.send_httpx( + MethodType(original_async_send, self), + request, + *args, + **kwargs, + ) + finally: + _httpx_active.reset(token) + + _state.original_async_send = original_async_send + _state.async_send_patch = async_send + httpx.AsyncClient.send = async_send # type: ignore[method-assign] + + +def _resolve_mcp_client(*, required: bool) -> Any | None: + try: + from mcp import ClientSession + except ImportError as error: + if required: + raise ImportError( + 'Cannot instrument MCP calls. Install the "mcp" extra: pip install "pympp[mcp]"' + ) from error + return None + _ = ClientSession.call_tool + return ClientSession + + +def _install_mcp_patch(client_session: Any) -> None: + if _state.original_mcp_call_tool is not None: + return + original_call_tool = client_session.call_tool + + async def call_tool( + self: Any, + name: str, + arguments: dict[str, Any] | None = None, + *args: Any, + **kwargs: Any, + ) -> Any: + if mcp_payment_flow_active() or _mcp_active.get(): + return await original_call_tool(self, name, arguments, *args, **kwargs) + runtime = _select_runtime("mcp") + if runtime is None: + return await original_call_tool(self, name, arguments, *args, **kwargs) + token = _mcp_active.set(True) + try: + return await runtime.call_mcp_tool( + MethodType(original_call_tool, self), + name, + arguments, + *args, + **kwargs, + ) + finally: + _mcp_active.reset(token) + + client_session.call_tool = call_tool + _state.original_mcp_call_tool = original_call_tool + _state.mcp_call_tool_patch = call_tool + _state.mcp_client_session = client_session + + +def _restore_unused_patches() -> None: + if not any(binding.active and binding.httpx for binding in _state.bindings): + if ( + _state.sync_send_patch is not None + and httpx.Client.send is _state.sync_send_patch + and _state.original_sync_send is not None + ): + httpx.Client.send = _state.original_sync_send # type: ignore[method-assign] + if ( + _state.async_send_patch is not None + and httpx.AsyncClient.send is _state.async_send_patch + and _state.original_async_send is not None + ): + httpx.AsyncClient.send = _state.original_async_send # type: ignore[method-assign] + _state.original_sync_send = None + _state.sync_send_patch = None + _state.original_async_send = None + _state.async_send_patch = None + + if not any(binding.active and binding.mcp for binding in _state.bindings): + if ( + _state.mcp_client_session is not None + and _state.mcp_call_tool_patch is not None + and _state.mcp_client_session.call_tool is _state.mcp_call_tool_patch + and _state.original_mcp_call_tool is not None + ): + _state.mcp_client_session.call_tool = _state.original_mcp_call_tool + _state.original_mcp_call_tool = None + _state.mcp_call_tool_patch = None + _state.mcp_client_session = None diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index 2fdb01fd..b0861ef7 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -27,6 +27,9 @@ _T = TypeVar("_T") _PAYMENT_FLOW_ACTIVE: ContextVar[bool] = ContextVar("mpp_payment_flow_active", default=False) +_MCP_FLOW_ACTIVE: ContextVar[bool] = ContextVar("mpp_mcp_flow_active", default=False) +_payment_flow_count = 0 +_payment_flow_lock = threading.Lock() def payment_flow_active() -> bool: @@ -34,6 +37,17 @@ def payment_flow_active() -> bool: return _PAYMENT_FLOW_ACTIVE.get() +def payment_flow_active_in_process() -> bool: + """Return whether any context is creating a payment credential.""" + with _payment_flow_lock: + return _payment_flow_count > 0 + + +def mcp_payment_flow_active() -> bool: + """Return whether the current context is inside an MCP payment adapter.""" + return _MCP_FLOW_ACTIVE.get() + + @runtime_checkable class Method(Protocol): """Payment method interface for client-side credential creation.""" @@ -299,6 +313,20 @@ async def call_mcp_tool( **kwargs: Any, ) -> Any: """Call an MCP tool with automatic payment handling, preserving result type.""" + token = _MCP_FLOW_ACTIVE.set(True) + try: + return await self._call_mcp_tool(call_tool, name, arguments, *args, **kwargs) + finally: + _MCP_FLOW_ACTIVE.reset(token) + + async def _call_mcp_tool( + self, + call_tool: Any, + name: str, + arguments: dict[str, Any] | None = None, + *args: Any, + **kwargs: Any, + ) -> Any: from mpp.extensions.mcp.client import ( PaymentOutcomeUnknownError, _extract_challenges, @@ -471,7 +499,11 @@ async def _create_credential( *, event_payload: dict[str, Any] | None = None, ) -> Credential: + global _payment_flow_count + token = _PAYMENT_FLOW_ACTIVE.set(True) + with _payment_flow_lock: + _payment_flow_count += 1 try: payload = { "challenge": challenge, @@ -495,6 +527,8 @@ async def _create_credential( ) return credential finally: + with _payment_flow_lock: + _payment_flow_count -= 1 _PAYMENT_FLOW_ACTIVE.reset(token) async def emit_event(self, name: str, payload: EventPayload) -> Any: diff --git a/tests/test_agent_instrumentation.py b/tests/test_agent_instrumentation.py new file mode 100644 index 00000000..e95b6209 --- /dev/null +++ b/tests/test_agent_instrumentation.py @@ -0,0 +1,481 @@ +"""Tests for scoped HTTP and MCP instrumentation.""" + +from __future__ import annotations + +import asyncio +import builtins +import threading +from typing import Any +from unittest.mock import AsyncMock + +import httpx +import pytest + +from mpp import Challenge +from mpp.agent import _bindings, _state, instrument +from mpp.extensions.mcp import META_CREDENTIAL, McpClient +from mpp.runtime import PaymentRuntime +from tests import make_credential + + +class MockMethod: + name = "tempo" + _intents = {"charge": True} + + def __init__(self, label: str) -> None: + self.label = label + self.create_credential = AsyncMock(side_effect=self._create) + + async def _create(self, challenge: Challenge): + return make_credential({"runtime": self.label}, challenge_id=challenge.id) + + +def payment_required() -> httpx.Response: + challenge = Challenge(id="test-id", method="tempo", intent="charge", request={}) + return httpx.Response( + 402, + headers={"www-authenticate": challenge.to_www_authenticate("example.com")}, + ) + + +def paid_transport(requests: list[httpx.Request]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return payment_required() if len(requests) == 1 else httpx.Response(200, content=b"paid") + + return httpx.MockTransport(handler) + + +@pytest.fixture(autouse=True) +def clean_instrumentation_state(): + sync_send = httpx.Client.send + async_send = httpx.AsyncClient.send + _bindings.set(None) + yield + with _state.lock: + for binding in _state.bindings: + binding.active = False + _state.bindings.clear() + if _state.mcp_client_session is not None and _state.original_mcp_call_tool is not None: + _state.mcp_client_session.call_tool = _state.original_mcp_call_tool + _state.original_sync_send = None + _state.sync_send_patch = None + _state.original_async_send = None + _state.async_send_patch = None + _state.original_mcp_call_tool = None + _state.mcp_call_tool_patch = None + _state.mcp_client_session = None + httpx.Client.send = sync_send + httpx.AsyncClient.send = async_send + _bindings.set(None) + + +@pytest.mark.asyncio +async def test_instruments_existing_sync_and_async_clients() -> None: + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + sync_client = httpx.Client(transport=paid_transport(sync_requests)) + async_client = httpx.AsyncClient(transport=paid_transport(async_requests)) + method = MockMethod("one") + runtime = PaymentRuntime([method]) + handle = instrument(runtime, mcp=False) + try: + sync_response = sync_client.get("https://example.com/sync") + async_response = await async_client.get("https://example.com/async") + finally: + handle.disable() + sync_client.close() + await async_client.aclose() + runtime.close() + + assert sync_response.status_code == async_response.status_code == 200 + assert len(sync_requests) == len(async_requests) == 2 + assert method.create_credential.call_count == 2 + + +@pytest.mark.asyncio +async def test_concurrent_contexts_select_their_own_runtime() -> None: + methods = [MockMethod("a"), MockMethod("b")] + runtimes = [PaymentRuntime([method]) for method in methods] + + async def call(runtime: PaymentRuntime) -> None: + requests: list[httpx.Request] = [] + async with httpx.AsyncClient(transport=paid_transport(requests)) as client: + with instrument(runtime, mcp=False): + assert (await client.get("https://example.com/paid")).status_code == 200 + + try: + await asyncio.gather(*(call(runtime) for runtime in runtimes)) + finally: + for runtime in runtimes: + runtime.close() + + assert [method.create_credential.call_count for method in methods] == [1, 1] + + +@pytest.mark.asyncio +async def test_active_payment_does_not_block_another_local_context() -> None: + started = asyncio.Event() + release = asyncio.Event() + + class BlockingMethod(MockMethod): + async def _create(self, challenge: Challenge): + started.set() + await release.wait() + return await super()._create(challenge) + + methods = [BlockingMethod("a"), MockMethod("b")] + runtimes = [PaymentRuntime([method]) for method in methods] + + async def first_call() -> int: + requests: list[httpx.Request] = [] + async with httpx.AsyncClient(transport=paid_transport(requests)) as client: + with instrument(runtimes[0], mcp=False): + return (await client.get("https://example.com/first")).status_code + + task = asyncio.create_task(first_call()) + await started.wait() + second_requests: list[httpx.Request] = [] + try: + async with httpx.AsyncClient(transport=paid_transport(second_requests)) as client: + with instrument(runtimes[1], mcp=False): + second_status = (await client.get("https://example.com/second")).status_code + finally: + release.set() + first_status = await task + for runtime in runtimes: + runtime.close() + + assert first_status == second_status == 200 + assert len(second_requests) == 2 + assert [method.create_credential.call_count for method in methods] == [1, 1] + + +@pytest.mark.asyncio +async def test_disabled_context_does_not_fall_back_to_another_runtime() -> None: + other_method = MockMethod("other") + other_runtime = PaymentRuntime([other_method]) + local_runtime = PaymentRuntime([MockMethod("local")]) + ready = asyncio.Event() + release = asyncio.Event() + + async def hold_other_context() -> None: + with instrument(other_runtime, mcp=False): + ready.set() + await release.wait() + + task = asyncio.create_task(hold_other_context()) + await ready.wait() + handle = instrument(local_runtime, mcp=False) + handle.disable() + requests: list[httpx.Request] = [] + try: + async with httpx.AsyncClient(transport=paid_transport(requests)) as client: + assert (await client.get("https://example.com/paid")).status_code == 402 + finally: + release.set() + await task + local_runtime.close() + other_runtime.close() + + assert len(requests) == 1 + other_method.create_credential.assert_not_called() + + +@pytest.mark.asyncio +async def test_preexisting_async_context_does_not_use_process_fallback() -> None: + method = MockMethod("one") + runtime = PaymentRuntime([method]) + ready = asyncio.Event() + release = asyncio.Event() + requests: list[httpx.Request] = [] + + async def call() -> int: + ready.set() + await release.wait() + async with httpx.AsyncClient(transport=paid_transport(requests)) as client: + return (await client.get("https://example.com/paid")).status_code + + task = asyncio.create_task(call()) + await ready.wait() + handle = instrument(runtime, mcp=False) + try: + release.set() + status = await task + finally: + handle.disable() + runtime.close() + + assert status == 402 + assert len(requests) == 1 + method.create_credential.assert_not_called() + + +def test_bare_thread_uses_only_unambiguous_process_runtime() -> None: + method = MockMethod("one") + runtime = PaymentRuntime([method]) + requests: list[httpx.Request] = [] + client = httpx.Client(transport=paid_transport(requests)) + handle = instrument(runtime, mcp=False) + result: list[int] = [] + thread = threading.Thread( + target=lambda: result.append(client.get("https://example.com/paid").status_code) + ) + try: + thread.start() + thread.join(timeout=2) + finally: + handle.disable() + client.close() + runtime.close() + + assert thread.is_alive() is False + assert result == [200] + assert method.create_credential.call_count == 1 + + +def test_bare_thread_fails_closed_with_multiple_runtimes() -> None: + methods = [MockMethod("a"), MockMethod("b")] + runtimes = [PaymentRuntime([method]) for method in methods] + handles = [instrument(runtime, mcp=False) for runtime in runtimes] + requests: list[httpx.Request] = [] + client = httpx.Client(transport=paid_transport(requests)) + result: list[int] = [] + thread = threading.Thread( + target=lambda: result.append(client.get("https://example.com/paid").status_code) + ) + try: + thread.start() + thread.join(timeout=2) + finally: + for handle in handles: + handle.disable() + client.close() + for runtime in runtimes: + runtime.close() + + assert result == [402] + assert len(requests) == 1 + assert all(method.create_credential.call_count == 0 for method in methods) + + +def test_out_of_order_disable_restores_exact_originals() -> None: + sync_send = httpx.Client.send + async_send = httpx.AsyncClient.send + runtimes = [PaymentRuntime([MockMethod("a")]), PaymentRuntime([MockMethod("b")])] + first = instrument(runtimes[0], mcp=False) + second = instrument(runtimes[1], mcp=False) + + first.disable() + assert httpx.Client.send is not sync_send + second.disable() + + assert httpx.Client.send is sync_send + assert httpx.AsyncClient.send is async_send + for runtime in runtimes: + runtime.close() + + +def test_disable_does_not_overwrite_a_later_patch() -> None: + sync_send = httpx.Client.send + async_send = httpx.AsyncClient.send + runtime = PaymentRuntime([MockMethod("one")]) + handle = instrument(runtime, mcp=False) + + def replacement(self: httpx.Client, request: httpx.Request, **kwargs: Any): + return sync_send(self, request, **kwargs) + + httpx.Client.send = replacement + try: + handle.disable() + assert httpx.Client.send is replacement + assert httpx.AsyncClient.send is async_send + finally: + httpx.Client.send = sync_send + runtime.close() + + +@pytest.mark.asyncio +async def test_explicit_sync_and_async_wrappers_take_precedence() -> None: + explicit_method = MockMethod("explicit") + global_method = MockMethod("global") + explicit_runtime = PaymentRuntime([explicit_method]) + global_runtime = PaymentRuntime([global_method]) + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + handle = instrument(global_runtime, mcp=False) + sync_client = explicit_runtime.wrap_client( + httpx.Client(transport=paid_transport(sync_requests)) + ) + async_client = explicit_runtime.wrap_async_client( + httpx.AsyncClient(transport=paid_transport(async_requests)) + ) + try: + assert sync_client.get("https://example.com/sync").status_code == 200 + assert (await async_client.get("https://example.com/async")).status_code == 200 + finally: + handle.disable() + sync_client.close() + await async_client.aclose() + explicit_runtime.close() + global_runtime.close() + + assert explicit_method.create_credential.call_count == 2 + global_method.create_credential.assert_not_called() + assert len(sync_requests) == len(async_requests) == 2 + + +def test_credential_http_is_not_recursively_instrumented() -> None: + internal_requests: list[httpx.Request] = [] + internal_statuses: list[int] = [] + + def internal_handler(request: httpx.Request) -> httpx.Response: + internal_requests.append(request) + return payment_required() + + class HttpMethod(MockMethod): + async def _create(self, challenge: Challenge): + if self.create_credential.call_count == 1: + + def request() -> None: + with httpx.Client(transport=httpx.MockTransport(internal_handler)) as client: + internal_statuses.append(client.get("https://rpc.example.com").status_code) + + thread = threading.Thread(target=request) + thread.start() + thread.join(timeout=2) + assert thread.is_alive() is False + return await super()._create(challenge) + + method = HttpMethod("one") + runtime = PaymentRuntime([method]) + requests: list[httpx.Request] = [] + client = runtime.wrap_client(httpx.Client(transport=paid_transport(requests))) + handle = instrument(runtime, mcp=False) + try: + assert client.get("https://example.com/paid").status_code == 200 + finally: + handle.disable() + client.close() + runtime.close() + + assert len(internal_requests) == 1 + assert internal_statuses == [402] + assert method.create_credential.call_count == 1 + + +class FakeMcpError(Exception): + def __init__(self) -> None: + self.code = -32042 + self.data = { + "challenges": [ + { + "id": "test-id", + "realm": "example.com", + "method": "tempo", + "intent": "charge", + "request": {}, + } + ] + } + + +class FakeSession: + def __init__(self, results: list[Any]) -> None: + self.results = results + self.calls: list[dict[str, Any]] = [] + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + *args: Any, + **kwargs: Any, + ) -> Any: + self.calls.append(kwargs) + result = self.results.pop(0) + if isinstance(result, Exception): + raise result + return result + + +@pytest.mark.asyncio +async def test_mcp_instrumentation_preserves_shape_and_explicit_precedence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import mcp + + monkeypatch.setattr(mcp, "ClientSession", FakeSession) + global_method = MockMethod("global") + explicit_method = MockMethod("explicit") + global_runtime = PaymentRuntime([global_method]) + explicit_runtime = PaymentRuntime([explicit_method]) + handle = instrument(global_runtime, httpx=False, mcp=True) + raw_result = object() + raw_session = FakeSession([FakeMcpError(), raw_result]) + explicit_result = object() + explicit_session = FakeSession([FakeMcpError(), explicit_result]) + try: + result = await raw_session.call_tool("paid", meta={"trace": "abc"}) + wrapped = await McpClient(explicit_session, runtime=explicit_runtime).call_tool("paid") + finally: + handle.disable() + global_runtime.close() + explicit_runtime.close() + + assert result is raw_result + assert raw_session.calls[1]["meta"]["trace"] == "abc" + assert META_CREDENTIAL in raw_session.calls[1]["meta"] + assert wrapped.result is explicit_result + assert global_method.create_credential.call_count == 1 + assert explicit_method.create_credential.call_count == 1 + + +def test_required_mcp_failure_is_transactional(monkeypatch: pytest.MonkeyPatch) -> None: + sync_send = httpx.Client.send + async_send = httpx.AsyncClient.send + real_import = builtins.__import__ + + def missing_mcp(name: str, *args: Any, **kwargs: Any): + if name == "mcp": + raise ImportError("missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", missing_mcp) + runtime = PaymentRuntime([]) + try: + with pytest.raises(ImportError, match="Cannot instrument MCP"): + instrument(runtime, mcp=True) + finally: + runtime.close() + + assert httpx.Client.send is sync_send + assert httpx.AsyncClient.send is async_send + + +def test_mcp_patch_failure_is_transactional(monkeypatch: pytest.MonkeyPatch) -> None: + import mcp + + sync_send = httpx.Client.send + async_send = httpx.AsyncClient.send + + class FrozenSessionMeta(type): + def __setattr__(cls, name: str, value: Any) -> None: + if name == "call_tool": + raise RuntimeError("frozen") + super().__setattr__(name, value) + + class FrozenSession(metaclass=FrozenSessionMeta): + async def call_tool(self, name: str) -> Any: + return name + + monkeypatch.setattr(mcp, "ClientSession", FrozenSession) + runtime = PaymentRuntime([]) + try: + with pytest.raises(RuntimeError, match="frozen"): + instrument(runtime, mcp=True) + finally: + runtime.close() + + assert httpx.Client.send is sync_send + assert httpx.AsyncClient.send is async_send