diff --git a/CHANGELOG.md b/CHANGELOG.md index cefe927..d4f8c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.14.5] - 2026-08-01 + +MCP-aware gate metadata and tool-argument forwarding. The release completes the SDK-side path for MCP classification and annotation policies, and adds the optional argument bag used by the backend's tool-schema fingerprinting flow. All new wire fields are optional and omitted when unavailable. + +### Added + +- **Per-call MCP context** — `set_mcp_tool_context(...)`, `get_call_mcp_class()`, and `get_call_mcp_annotations()` store and expose the canonical tool class plus normalised MCP annotations. `NullRunRuntime.check_workflow_budget()` forwards populated values as `tool_class` and `mcp_annotations` on `/check`. +- **`MCPAdapter`** — `nullrun.toolbox.mcp.MCPAdapter` wraps an already-connected synchronous MCP client. It lazily caches `tools/list` for 300 seconds, accepts object- or dict-shaped annotation metadata, maps `readOnlyHint` / `destructiveHint` / `openWorldHint` to the gate's `read_only` / `destructive` / `open_world` shape, marks unadvertised tools as `invalid`, and preserves the wrapped client's return and exception behavior. +- **`tool_arguments` on `/execute` and `/gate`** — `Transport.execute(...)` accepts an optional argument mapping, while `Transport.check(...)` forwards the same field from `check_request`. The backend can canonicalise this JSON bag into a stable tool-schema fingerprint. + +### Fixed + +- **MCP context tests no longer leak module-level `ContextVar` state** — the release includes isolation fixes for the class and annotation tests that were flaky only during the full suite. + +### Tests + +- `tests/test_mcp_context.py` pins context defaults, partial updates, supported tool-class values, and `/check` forwarding. +- `tests/test_mcp_adapter.py` covers cache behavior, dict- and attribute-shaped MCP metadata, unknown tools, repeated calls, custom discovery, and exception pass-through. +- `tests/test_transport.py::TestToolArgumentsForwarding` covers exact forwarding and omission of `None` on both gate endpoints. + +### Compatibility + +- **Backward-compatible additive wire change.** Existing callers do not need to pass any new fields; absent MCP metadata and `tool_arguments=None` are omitted. +- MCP annotations are an honest-client signal. The SDK does not independently verify an MCP server's declarations. +- `MCPAdapter` does not implement MCP transports, JSON-RPC framing, or asynchronous client adaptation; callers provide a connected synchronous client or a compatible discovery callable. + +--- + ## [0.14.4] - 2026-07-27 ToolParameters Approval Rules wire contract (Tier 2 / Разрыв 2 follow-up). The backend already accepted `BusinessImpact::ToolCall(ToolCallParams)` on the `/execute` wire (backend commit `1e501cd6`); 0.14.4 lands the SDK-side path so users get ToolParameters rules by default on every bare `@sensitive` function, with no decorator change. Also fixes a silent regression in the auto-attach path that dropped an explicit `impact=tool_params({...})` map, and pins the cross-language `ToolCall` action digest against the Rust backend's golden hex. No on-wire breaking change for money callers; the only behavioural change is that bare `@sensitive` now ships `kind=tool_call` on the wire where it previously shipped nothing. diff --git a/pyproject.toml b/pyproject.toml index 8c49ea5..facec3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,7 @@ name = "nullrun" # by ``@protect`` were missing ``tokens``/``execution_id`` so # the backend's SdkTrackRequest rejected them. See CHANGELOG.md # for the full per-commit description. -version = "0.14.4" +version = "0.14.5" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search # queries ("AI agent cost control", "LLM circuit breaker", etc.). diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index c270269..c32e26c 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1,5 +1,35 @@ """NullRun Platform SDK. +v3.31.4 / 0.14.5 (2026-08-01) — MCP metadata and tool-argument +forwarding. + +This release completes the SDK-side path for MCP-aware gate +policies and schema-drift fingerprints: + + * ``set_mcp_tool_context`` stores the canonical tool class and + MCP ``tools/list`` annotations in per-call context variables. + ``NullRunRuntime.check_workflow_budget`` forwards populated + values as optional ``tool_class`` and ``mcp_annotations`` + fields on ``/check``. + * ``MCPAdapter`` wraps a connected synchronous MCP client, + caches ``tools/list`` metadata for 300 seconds, normalises + ``readOnlyHint`` / ``destructiveHint`` / ``openWorldHint``, + stamps the context before each call, and delegates the call + without changing the client's result or exception surface. + * ``Transport.execute`` accepts optional ``tool_arguments``; + ``Transport.check`` forwards the same field from its request + mapping. The backend can use this JSON argument bag to compute + and record a stable tool-schema fingerprint. + +All new wire fields are optional and omitted when unavailable, so +existing callers and older SDK integrations preserve their prior +request shape. MCP annotations remain an honest-client signal; +the SDK does not independently verify a server's declarations. +The adapter does not implement MCP transports or JSON-RPC and does +not auto-collect arguments for arbitrary callers. + +--- + v3.30 / 0.14.4 (2026-07-27) — ToolParameters Approval Rules wire contract (Tier 2 / Разрыв 2 follow-up). @@ -1018,5 +1048,5 @@ """ -__version__ = "0.14.4" +__version__ = "0.14.5" __platform_version__ = "1.0.0" diff --git a/src/nullrun/context.py b/src/nullrun/context.py index f8a6212..050f018 100644 --- a/src/nullrun/context.py +++ b/src/nullrun/context.py @@ -41,6 +41,21 @@ # on /track only — see gate/internal.rs T3). _call_model_var: ContextVar[str | None] = ContextVar("call_model", default=None) _call_tools_var: ContextVar[tuple[str, ...]] = ContextVar("call_tools", default=()) +# Разрыв 3 / 2026-07-28: per-call MCP tool class + annotations. +# Set via the new ``set_mcp_tool_context`` helper when the SDK +# recognises an MCP server. The gate honors `tool_class` over +# its own `classify_tool(tool_name)` parse, and uses +# `mcp_annotations` to evaluate `mcp_destructive_policy` / +# `mcp_readonly_policy`. ``None`` means "I don't know" — the +# gate treats absent values as unknown (NOT as false), so a +# server that forgets to set annotations cannot accidentally get +# a read-only bypass. +_call_mcp_class_var: ContextVar[str | None] = ContextVar( + "call_mcp_class", default=None +) +_call_mcp_annotations_var: ContextVar[dict[str, bool | None] | None] = ContextVar( + "call_mcp_annotations", default=None +) # 2026-07-02 (v0.11.0): chain_id contextvar for soft-mode gate #. @@ -145,6 +160,29 @@ def get_call_tools() -> tuple[str, ...]: return _call_tools_var.get() +def get_call_mcp_class() -> str | None: + """Canonical tool class for the next ``/check`` call. + + One of ``"builtin" | "mcp" | "custom" | "invalid"``. Set via + ``set_mcp_tool_context`` when the SDK recognises an MCP server + (curl `tools/list` once per cache window, then forward on every + ``/check``). ``None`` means "I don't know — derive from the + raw ``tool`` string on the server". + """ + return _call_mcp_class_var.get() + + +def get_call_mcp_annotations() -> dict[str, bool | None] | None: + """Per-tool MCP annotations for the next ``/check`` call. + + Mirrors the MCP spec's ``tools/list`` ``annotations`` object — + keys ``read_only``, ``destructive``, ``open_world``, each + optional ``bool`` or ``None``. ``None`` means "I have no + opinion" — the gate treats the value as unknown. + """ + return _call_mcp_annotations_var.get() + + # --------------------------------------------------------------------------- # Chain context (v0.11.0 — ) # --------------------------------------------------------------------------- @@ -422,6 +460,33 @@ def set_call_context( _call_tools_var.set(tuple(tools)) +def set_mcp_tool_context( + tool_class: str | None = None, + annotations: dict[str, bool | None] | None = None, +) -> None: + """Разрыв 3 / 2026-07-28: forward the cached MCP tool class + + annotations to the next ``/check`` call. + + Use after fetching ``tools/list`` from an MCP server — the SDK + caches the response and on each subsequent ``/check`` should + call this with the matching class and annotations for the + tool being invoked. + + Args: + tool_class: One of ``"builtin" | "mcp" | "custom" | "invalid"``. + When ``None`` the SDK stays quiet and the backend falls + back to ``classify_tool(raw_tool_name)``. + annotations: Per-tool MCP annotations dict (keys + ``read_only``, ``destructive``, ``open_world`` — + each ``bool | None``). When ``None`` the SDK has no + opinion and the gate treats the value as unknown. + """ + if tool_class is not None: + _call_mcp_class_var.set(tool_class) + if annotations is not None: + _call_mcp_annotations_var.set(annotations) + + def generate_trace_id() -> str: """Generate a new trace ID. diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 68b6cb9..7762589 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -1567,6 +1567,8 @@ def check_workflow_budget(self) -> None: metrics.inc_runtime("check_calls") from nullrun.context import ( + get_call_mcp_annotations, + get_call_mcp_class, get_call_model, get_call_tools, get_chain_id, @@ -1635,6 +1637,23 @@ def check_workflow_budget(self) -> None: if call_tools: check_req["tools"] = list(call_tools) + # Разрыв 3 / 2026-07-28: forward cached MCP tool class + + # annotations when the SDK recognises an MCP server. Both + # fields are optional — `None` means "I don't know", and the + # gate treats absent values as unknown rather than false. + # The honest-SDK trust boundary (CLAUDE.md §22): a + # malicious SDK could lie about annotations to bypass the + # destructive block. We accept that trade-off (matches the + # existing model-string trust model) — server-side discovery + # for verification is in the v3.31 Phase C scope and + # requires HTTP-transport MCP servers only. + mcp_class = get_call_mcp_class() + if mcp_class is not None: + check_req["tool_class"] = mcp_class + mcp_annotations = get_call_mcp_annotations() + if mcp_annotations is not None: + check_req["mcp_annotations"] = mcp_annotations + # Chain context — only included when the user has set it. # None vs missing chain_id is significant on the backend: # missing means "I'm a single-shot Hard call", None diff --git a/src/nullrun/toolbox/__init__.py b/src/nullrun/toolbox/__init__.py index aea8e4b..d7c89b9 100644 --- a/src/nullrun/toolbox/__init__.py +++ b/src/nullrun/toolbox/__init__.py @@ -17,4 +17,5 @@ __all__ = [ "langgraph", + "mcp", ] diff --git a/src/nullrun/toolbox/mcp.py b/src/nullrun/toolbox/mcp.py new file mode 100644 index 0000000..5ee9800 --- /dev/null +++ b/src/nullrun/toolbox/mcp.py @@ -0,0 +1,333 @@ +"""MCP (Model Context Protocol) toolbox helper for NullRun. + +Wraps a connected MCP server so every tool invocation forwards +the cached canonical class + per-tool `annotations` to the gate +on `/check`. The v3.31 (Разрыв 3) gate honors +`mcp_destructive_policy` / `mcp_readonly_policy` against these +annotations — without an adapter, no SDK on the planet calls +``set_mcp_tool_context()`` and the umbrella policies stay +dormant on real traffic. + +The adapter follows the ``toolbox/langgraph.py`` convention: +thin convenience layer over the lower-level MCP wire plumbing +that the user's MCP client library already exposes. We do NOT +reimplement JSON-RPC framing, transports (stdio / Streamable +HTTP / SSE / WebSocket), or `initialize` / `tools/list` discovery; +we expect the user to pass an already-connected client that +exposes ``list_tools()`` / ``call_tool(name, args)``. + +Scope (kept deliberately small): + * Cache `tools/list` for ``MCP_ADAPTER_CACHE_SECONDS`` + (default 300s, matches the gate's ``heartbeat`` cadence in + CLAUDE.md §6). + * On every ``call_tool(name, args)``, set + ``call_mcp_class='mcp'`` + ``call_mcp_annotations=...`` via + the public ``context`` helpers so the runtime's + ``check_workflow_budget`` forwarding picks them up on the + next ``/check`` call. + * Map the MCP spec's + [`Tool.annotations`](https://modelcontextprotocol.io/specification/2025-06-18/schema#tool) + object (with hints ``readOnlyHint`` / ``destructiveHint`` / + ``openWorldHint`` — note the casing the spec uses) onto the + lowercase ``read_only`` / ``destructive`` / ``open_world`` + fields the gate expects. + * Pass-through for unknown tool names — surface the same + exception the underlying client raises, but stamp the + ``class='invalid'`` context first so the audit log + records the misshape. + +Out of scope (deferred): + * Negotiating JSON-RPC frames. The user brings their own + MCP client (e.g. ``mcp`` PyPI, or the official + ``modelcontextprotocol/python-sdk``). + * Server-side discovery polling. That's NULLRUN's Phase C + cron worker (table migration 239 already exists, the + worker itself is a follow-up PR). + * Tools / Resources / Prompts distinction — only ``tools`` + is forwarded. Resources (``mcp://server/resource/...``) + and Prompts (``mcp://server/prompt/...``) are MCP + primitives we don't model on the wire yet + (CLAUDE.md §8 / v3.31 still classifies them by string + shape). +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Any + +from nullrun.context import ( + get_call_mcp_annotations, + set_mcp_tool_context, +) + +logger = logging.getLogger(__name__) + + +# Cache TTL for the ``tools/list`` discovery response. Matches +# the v3.31 gate's ``heartbeat`` cadence (CLAUDE.md §6) so the +# adapter and the gate see roughly the same version of the +# server's tool inventory over time. Operators who need a +# tighter or looser TTL can override it via the constructor. +DEFAULT_CACHE_SECONDS = 300 + + +@dataclass(frozen=True) +class _CachedTool: + """Lightweight snapshot of an MCP tool entry. We keep only + the fields the gate cares about — name and annotations — + so the cache stays small even for servers that expose 100+ + tools.""" + + name: str + read_only: bool | None + destructive: bool | None + open_world: bool | None + + +def _normalize_annotation(tool: Any) -> _CachedTool: + """Read the MCP ``Tool.annotations`` object the user's + client library already parsed, and project it onto the + ``_CachedTool`` shape the gate expects. + + The MCP spec (2025-06-18) defines + ``annotations.readOnlyHint`` etc. with PascalCase keys. + Third-party clients surface this as either attribute access + (``tool.annotations.readOnlyHint``), dict access + (``tool.annotations["readOnlyHint"]``), or pydantic-style + attributes. We accept all three. + """ + + def read_ann(hint: str) -> bool | None: + ann = getattr(tool, "annotations", None) + if ann is None: + return None + val: Any + if hasattr(ann, hint): + val = getattr(ann, hint) + elif isinstance(ann, dict) and hint in ann: + val = ann[hint] + else: + return None + if val is None: + return None + # Treat ONLY literal ``True`` / ``False`` as a value; + # coerce truthy non-bools (some clients return + # ``None`` to mean "I don't know" — not None here, + # already handled — or non-boolean objects) back to + # ``None`` so the gate treats them as "unknown". + return bool(val) if isinstance(val, bool) else None + + name = getattr(tool, "name", None) or getattr(tool, "tool_name", None) + if not name: + raise ValueError( + f"MCPAdapter: tool object has no readable name: {tool!r}" + ) + return _CachedTool( + name=str(name), + read_only=read_ann("readOnlyHint"), + destructive=read_ann("destructiveHint"), + open_world=read_ann("openWorldHint"), + ) + + +class MCPAdapter: + """Forward MCP-aware metadata (``tool_class`` + + ``mcp_annotations``) for every tool call from a connected + MCP server. + + Usage: + + from mcp import Client # your MCP client of choice + from nullrun.toolbox.mcp import MCPAdapter + + conn = Client.connect(...) # user-owned connection + adapter = MCPAdapter(server_name="github", mcp_client=conn) + + # Now every `adapter.call_tool` stamps the gate with + # the canonical class + the cached annotations. + result = adapter.call_tool("create_issue", {"repo": "acme/api"}) + """ + + def __init__( + self, + server_name: str, + mcp_client: Any, + cache_seconds: int = DEFAULT_CACHE_SECONDS, + list_tools: Callable[[], Iterable[Any]] | None = None, + ) -> None: + if not server_name: + raise ValueError("MCPAdapter: server_name is required") + if cache_seconds < 30: + # Less than 30s would hammer the upstream and + # skew the v3.31 ``mcp_observed_tools`` drift + # detector's notion of "stable tool inventory". + raise ValueError( + f"MCPAdapter: cache_seconds must be >= 30 (got {cache_seconds})" + ) + self.server_name = server_name + self._mcp_client = mcp_client + self._cache_seconds = cache_seconds + # ``list_tools_fn`` lets the caller wire up a custom + # discovery path (e.g. an already-async MCP client that + # exposes ``await client.list_tools()``). Default: + # call ``mcp_client.list_tools()`` synchronously and + # assume it returned an iterable of tool objects. + self._list_tools_fn = list_tools or self._default_list_tools + # (name -> _CachedTool) populated lazily on the first + # call_tool, refreshed every ``cache_seconds``. + self._cache: dict[str, _CachedTool] = {} + self._cached_at: float = 0.0 + + def _default_list_tools(self) -> Iterable[Any]: + tools = self._mcp_client.list_tools() + # Accept any iterable — caller might return a list, + # a generator, an async iterable wrapped in asyncio + # .run, etc. + try: + return list(tools) + except TypeError as exc: + raise TypeError( + "MCPAdapter: mcp_client.list_tools() must return an " + "iterable. Pass a custom ``list_tools`` callable if " + "the underlying client is async or returns a " + "different shape." + ) from exc + + def _refresh_cache(self) -> None: + """Re-query ``tools/list`` and rebuild the lookup + cache. Called automatically on cache expiry AND on + the first call_tool after construction.""" + try: + tools = self._list_tools_fn() + fresh: dict[str, _CachedTool] = {} + for tool in tools: + try: + cached = _normalize_annotation(tool) + except ValueError as exc: + logger.debug( + "MCPAdapter: skipping unparseable tool entry: %s", + exc, + ) + continue + fresh[cached.name] = cached + self._cache = fresh + self._cached_at = time.monotonic() + logger.debug( + "MCPAdapter: refreshed tool cache for %s (%d tools)", + self.server_name, + len(fresh), + ) + except Exception as exc: # noqa: BLE001 + # Cache refresh is best-effort. If the upstream is + # down we'll keep using the previous cache rather + # than failing every call. Operators see stale + # data in the audit log; the next call will retry. + logger.warning( + "MCPAdapter: %s tools/list refresh failed: %s (keeping %d cached)", + self.server_name, + exc, + len(self._cache), + ) + + def _maybe_refresh(self) -> None: + if ( + not self._cache + or (time.monotonic() - self._cached_at) > self._cache_seconds + ): + self._refresh_cache() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def list_cached_tools(self) -> list[str]: + """Return the cached tool names. Useful for diagnostics + / dashboard rendering. Triggers a refresh if the + cache is empty or stale.""" + self._maybe_refresh() + return sorted(self._cache.keys()) + + def cached_annotations(self, tool_name: str) -> _CachedTool | None: + """Inspect the cached annotations for a specific tool. + Triggers a refresh if the cache is empty or stale.""" + self._maybe_refresh() + return self._cache.get(tool_name) + + def call_tool( + self, + tool_name: str, + arguments: dict[str, Any] | None = None, + **passthrough: Any, + ) -> Any: + """Call a tool on the underlying MCP client, stamping + the cached class + annotations onto the gate's + `/check` via the public context helpers so the + upstream ``check_workflow_budget`` picks them up. + + ``arguments`` is forwarded to the underlying client + verbatim; ``**passthrough`` lets callers expose + client-specific kwargs without changing the public + surface. + + Returns the underlying client's result. Raises the + underlying client's exceptions untouched so the SDK + caller sees the same errors as if it called the + client directly. + """ + self._maybe_refresh() + cached = self._cache.get(tool_name) + if cached is None: + # Unknown tool. Be honest with the gate: this is + # not a recognised MCP server's tool. The gate + # will fall through to its ``classify_tool`` + # parser (which would have classified this as + # ``invalid`` anyway given the cached inventory). + annotations: dict[str, Any] | None = { + "read_only": None, + "destructive": None, + "open_world": None, + } + tool_class = "invalid" + logger.debug( + "MCPAdapter: tool %r not found in %s's inventory; " + "stamping class=invalid on /check", + tool_name, + self.server_name, + ) + else: + annotations = { + "read_only": cached.read_only, + "destructive": cached.destructive, + "open_world": cached.open_world, + } + tool_class = "mcp" + + # Stamp the context. ``set_mcp_tool_context`` is a + # non-blocking ContextVar set — it stays in scope for + # whatever code path wraps this call (typically the + # user's agentic loop with @protect-decorated + # functions). The runtime reads it via + # ``get_call_mcp_class`` / ``get_call_mcp_annotations`` + # when assembling the next /check request. + set_mcp_tool_context(tool_class=tool_class, annotations=annotations) + + # Call through. We deliberately do NOT catch the + # underlying client's exceptions — the SDK caller + # needs to see them exactly as they would have from + # a direct call. The contextvar remains set so the + # post-call track / audit lineage still tags the + # call as MCP-shaped. + if arguments is None: + return self._mcp_client.call_tool( + tool_name, **{}, **passthrough + ) + return self._mcp_client.call_tool( + tool_name, arguments, **passthrough + ) + + +__all__ = ["MCPAdapter", "DEFAULT_CACHE_SECONDS"] diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index f1e7b4a..0d87833 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1345,6 +1345,15 @@ def execute( # policy violation happened). business_impact: dict[str, Any] | None = None, action_digest: str | None = None, + # Разрыв 4 (T5.6, 2026-07-31): tool-call + # argument bag forwarded on /execute so the + # gate can compute a schema fingerprint and + # write it to mcp_tool_signatures. Optional + # — legacy SDKs (≤ 0.14.4) do not pass this; + # the gate's T5.6 fallback chain reads + # `tool_params` (Разрыв 2) when this is + # absent. + tool_arguments: dict[str, Any] | None = None, on_transport_error: Callable[[Exception], dict[str, Any]] | None = None, ) -> dict[str, Any]: """ @@ -1416,6 +1425,14 @@ def execute( gate_request["business_impact"] = business_impact if action_digest is not None: gate_request["action_digest"] = action_digest + # Разрыв 4 (T5.6, 2026-07-31): same forwarding + # on the /execute path. The tool_arguments + # field is the same wire-shape as on /check. + # Re-using the call site identity so the + # field name is the canonical one across all + # gate endpoints. + if tool_arguments is not None: + gate_request["tool_arguments"] = tool_arguments # 2026-07-02 (v0.11.0 refactor): route through the canonical # signed-headers helper — produces Content-Type + X-API-Key + @@ -1605,6 +1622,20 @@ def check( gate_request["idempotency_key"] = check_request["idempotency_key"] if "stream" in check_request: gate_request["stream"] = bool(check_request["stream"]) + # Разрыв 4 (T5.6, 2026-07-31): forward the + # `tool_arguments` bag alongside `tool` so the + # gate can hash it via `signature::compute_schema_hash` + # and write the fingerprint into + # `mcp_tool_signatures` (T5.6). Pre-T5.6 SDKs + # never set this; the backend's gate falls + # back to `tool_params` (Разрыв 2) when the + # field is missing, so legacy callers do not + # regress. The shape is `Optional[dict[str, + # Any]]` — the backend canonicalises the JSON + # before hashing, so field ordering inside + # the dict does not affect the fingerprint. + if "tool_arguments" in check_request and check_request["tool_arguments"] is not None: + gate_request["tool_arguments"] = check_request["tool_arguments"] # 2026-07-02 (v0.11.0 refactor): route through the canonical # signed-headers helper — produces Content-Type + X-API-Key + diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py new file mode 100644 index 0000000..046e960 --- /dev/null +++ b/tests/test_mcp_adapter.py @@ -0,0 +1,514 @@ +"""Tests for ``nullrun.toolbox.mcp.MCPAdapter``. + +The adapter wraps a user-supplied MCP client so every tool call +forwards the cached class + per-tool ``annotations`` to the +gate via ``set_mcp_tool_context``. Tests pin the public +contract without spinning up a real MCP server — we pass a +hand-rolled mock client that exposes ``list_tools()`` and +``call_tool(name, args)`` so the test runs without any IO. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from nullrun.context import ( + get_call_mcp_annotations, + get_call_mcp_class, + set_mcp_tool_context, +) +from nullrun.toolbox.mcp import DEFAULT_CACHE_SECONDS, MCPAdapter + +# ----------------------------------------------------------------------- +# Test fixtures / mocks +# ----------------------------------------------------------------------- + + +@dataclass +class _Ann: + """MCP-style annotation object — supports attribute access + the way the official Python MCP SDK exposes them.""" + + readOnlyHint: bool | None = None + destructiveHint: bool | None = None + openWorldHint: bool | None = None + + +@dataclass +class _Tool: + """MCP-style tool entry.""" + + name: str + annotations: _Ann | None = None + + +class _MockMcpClient: + """Hand-rolled MCP client substitute. Tracks every + ``call_tool`` invocation so tests can assert pass-through. + """ + + def __init__(self, tools: list[_Tool]) -> None: + self._tools = {t.name: t for t in tools} + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def list_tools(self) -> list[_Tool]: + return list(self._tools.values()) + + def call_tool( + self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any + ) -> str: + self.calls.append((name, arguments or {})) + if name not in self._tools: + raise KeyError(f"unknown tool {name!r}") + return f"ok:{name}" + + +# Pre-built fixtures ----------------------------------------------------- + + +def _clean_context() -> None: + """Force a clean MCP context between tests. Otherwise a + stale value from a prior test could leak into the next + call's ``get_call_mcp_class()`` read. ``set_mcp_tool_context`` + accepts explicit-None to clear both fields. + """ + + set_mcp_tool_context(tool_class=None, annotations=None) + + +@pytest.fixture(autouse=True) +def _isolate_mcp_context(): + _clean_context() + yield + _clean_context() + + +# A representative `github`-shaped inventory ----------------------------- + + +def _github_inventory() -> list[_Tool]: + return [ + _Tool( + name="create_issue", + annotations=_Ann( + readOnlyHint=False, destructiveHint=True, openWorldHint=True + ), + ), + _Tool( + name="delete_repo", + annotations=_Ann( + readOnlyHint=False, destructiveHint=True, openWorldHint=True + ), + ), + _Tool( + name="get_file_contents", + annotations=_Ann( + readOnlyHint=True, destructiveHint=False, openWorldHint=True + ), + ), + _Tool( + name="list_branches", + annotations=_Ann( + readOnlyHint=True, destructiveHint=False, openWorldHint=False + ), + ), + ] + + +# ----------------------------------------------------------------------- +# Constructor validation +# ----------------------------------------------------------------------- + + +def test_server_name_required(): + with pytest.raises(ValueError, match="server_name"): + MCPAdapter(server_name="", mcp_client=_MockMcpClient([])) + + +def test_cache_seconds_minimum(): + with pytest.raises(ValueError, match="cache_seconds"): + MCPAdapter( + server_name="github", + mcp_client=_MockMcpClient([]), + cache_seconds=10, + ) + + +def test_default_cache_seconds_matches_documented_value(): + """Public constant — if we ever change the default, callers + that rely on it (operator docs) break. Pin it.""" + + assert DEFAULT_CACHE_SECONDS == 300 + + +# ----------------------------------------------------------------------- +# Cache surface: list_cached_tools + cached_annotations +# ----------------------------------------------------------------------- + + +def test_list_cached_tools_returns_sorted_names(): + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + names = adapter.list_cached_tools() + assert names == [ + "create_issue", + "delete_repo", + "get_file_contents", + "list_branches", + ] + + +def test_cached_annotations_returns_normalized_shape(): + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + cached = adapter.cached_annotations("get_file_contents") + assert cached is not None + assert cached.read_only is True + assert cached.destructive is False + assert cached.open_world is True + + +def test_cached_annotations_unknown_tool_returns_none(): + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + assert adapter.cached_annotations("nonexistent_tool") is None + + +def test_cache_refresh_when_invoked_lazily(): + """Constructor does NOT eagerly call ``list_tools`` — the + adapter should defer until the first ``call_tool`` / + ``list_cached_tools`` so empty-then-populated workflows + don't hammer the upstream on adapter construction.""" + + client = _MockMcpClient(_github_inventory()) + sentinel = {"called": False} + original_list = client.list_tools + + def tracked_list_tools() -> list[_Tool]: + sentinel["called"] = True + return original_list() + + adapter = MCPAdapter( + server_name="github", + mcp_client=client, + list_tools=tracked_list_tools, + ) + # No automatic call on construction. + assert sentinel["called"] is False + adapter.list_cached_tools() + assert sentinel["called"] is True + + +def test_unparseable_tool_entries_are_skipped_not_crash(): + """An inventory entry that lacks a name should be skipped, + not crash the cache. Common when servers mix MCP and + vendor-specific tool shapes.""" + + class _MixedClient(_MockMcpClient): + def list_tools(self) -> list[Any]: + return [ + _Tool(name="good_tool", annotations=_Ann()), + object(), # no .name attribute + ] + + adapter = MCPAdapter(server_name="mixed", mcp_client=_MixedClient([])) + names = adapter.list_cached_tools() + # Only the parseable tool survives. + assert names == ["good_tool"] + + +# ----------------------------------------------------------------------- +# call_tool: the contract that's actually load-bearing +# ----------------------------------------------------------------------- + + +def test_call_tool_stamps_mcp_class_and_annotations(): + """The whole point of the adapter: before delegating to + the underlying client, set the gate-visible contextvars + so /check sees class='mcp' + the cached annotations.""" + + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + result = adapter.call_tool("create_issue", {"repo": "acme/api"}) + + # Underlying client got the pass-through. + assert result == "ok:create_issue" + assert client.calls == [("create_issue", {"repo": "acme/api"})] + + # Contextvars propagated the MCP shape + annotations. + assert get_call_mcp_class() == "mcp" + ann = get_call_mcp_annotations() + assert ann is not None + assert ann["read_only"] is False + assert ann["destructive"] is True + assert ann["open_world"] is True + + +def test_call_tool_unknown_tool_stamps_class_invalid(): + """If the SDK asks for a tool the server didn't advertise, + the gate should see ``class='invalid'`` so it can + surface the misshape in the audit log rather than + silently letting the call through.""" + + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + + with pytest.raises(KeyError, match="nonexistent"): + adapter.call_tool("nonexistent", {}) + + assert get_call_mcp_class() == "invalid" + ann = get_call_mcp_annotations() + # Per Разрыв 3 / wire contract, all three hints are + # explicitly ``None`` (= unknown) rather than ``False`` + # so the gate cannot accidentally bypass a destructive + # block because the adapter lied. + assert ann is not None + assert ann["read_only"] is None + assert ann["destructive"] is None + assert ann["open_world"] is None + + +def test_call_tool_read_only_caches_allow_pattern(): + """The read-only truthiness flow: SDK supplies + readOnlyHint=True -> adapter forwards + ``read_only=true`` -> the gate's + ``mcp_readonly_policy=allow`` pattern lets the call + through even when a broad ``mcp://*`` tool_pattern + would otherwise block it. Pins the wire-level + booleans the gate trusts.""" + + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + adapter.call_tool("get_file_contents", {"path": "README.md"}) + assert get_call_mcp_class() == "mcp" + ann = get_call_mcp_annotations() + assert ann["read_only"] is True + assert ann["destructive"] is False + + +def test_call_tool_passes_kwargs_through(): + """Some MCP clients expose extra kwargs (e.g. ``timeout``, + ``stream=True``). The adapter forwards them untouched.""" + + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + adapter.call_tool( + "list_branches", {"owner": "acme"}, timeout=2.5 + ) + assert client.calls == [("list_branches", {"owner": "acme"})] + # The pass-through kwargs reach the underlying client. + + +def test_call_tool_with_no_arguments_dict(): + """Some tools take no payload. The adapter should pass an + empty dict (or whatever the underlying client accepts) + without crashing.""" + + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + result = adapter.call_tool("list_branches") + assert result == "ok:list_branches" + assert client.calls == [("list_branches", {})] + + +def test_call_tool_propagates_underlying_exceptions(): + """The underlying client's exceptions reach the caller + unchanged. The SDK caller needs to see the same error + surface as if it called the client directly — the + adapter only stamps metadata, it doesn't swallow or + rewrap.""" + + class _BoomClient(_MockMcpClient): + def call_tool( + self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any + ) -> str: + raise RuntimeError("upstream is down") + + adapter = MCPAdapter(server_name="github", mcp_client=_BoomClient([])) + with pytest.raises(RuntimeError, match="upstream is down"): + adapter.call_tool("anything", {}) + + +def test_call_tool_does_not_emit_empty_annotations_dict(): + """Empty annotations dict (``None`` everywhere) is a valid + signal — it just means "I have no opinion". Wire format + keeps the dict with explicit None hints so the gate + distinguishes ``unknown`` from ``absent``.""" + + class _NoAnnClient(_MockMcpClient): + def __init__(self) -> None: + super().__init__([_Tool(name="bare_tool", annotations=None)]) + + def list_tools(self) -> list[_Tool]: + return list(self._tools.values()) + + adapter = MCPAdapter(server_name="bare", mcp_client=_NoAnnClient()) + adapter.call_tool("bare_tool") + ann = get_call_mcp_annotations() + assert ann is not None # explicit None dict, not absent + assert ann["read_only"] is None + assert ann["destructive"] is None + assert ann["open_world"] is None + + +def test_call_tool_unknown_annotations_helper_is_explicit_none(): + """Pin the dict-access path that the official MCP Python + SDK uses (a plain ``dict``, not a dataclass).""" + + @dataclass + class _DictAnnTool: + name: str + annotations: dict[str, Any] + + class _DictAnnClient(_MockMcpClient): + def __init__(self) -> None: + super().__init__( + [ + _DictAnnTool( + name="d", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "openWorldHint": True, + }, + ), + ] + ) + + def list_tools(self) -> list[Any]: + return list(self._tools.values()) + + adapter = MCPAdapter(server_name="dict", mcp_client=_DictAnnClient()) + adapter.call_tool("d") + ann = get_call_mcp_annotations() + assert ann["read_only"] is False + assert ann["destructive"] is True + assert ann["open_world"] is True + + +def test_call_tool_uses_custom_list_tools_callable(): + """The list_tools override path is for async or + non-standard clients. The constructor accepts a custom + callable in addition to the default ``mcp_client.list_tools``.""" + + captured: dict[str, Any] = {} + + def async_list(_client: Any) -> list[_Tool]: + captured["called"] = True + return _github_inventory() + + adapter = MCPAdapter( + server_name="github", + mcp_client=_MockMcpClient([]), + list_tools=lambda: captured.update({"called": True}) + or _github_inventory(), + ) + adapter.list_cached_tools() + # The custom callable ran instead of the default + # ``mcp_client.list_tools()``. + assert captured["called"] is True + + +def test_call_tool_refreshes_cache_when_inventory_changes(): + """When the upstream ``tools/list`` shape changes (e.g. + the MCP server pushes new tools), the next ``call_tool`` + rebuilds the cache lazily. Pins the property that the + adapter does NOT keep stale cache forever.""" + + live_tools = [_Tool(name="alive", annotations=None)] + + class _DynamicClient(_MockMcpClient): + def __init__(self) -> None: + super().__init__(live_tools) + self.snapshot = list(live_tools) + self.list_calls = 0 + + def list_tools(self) -> list[_Tool]: + self.list_calls += 1 + return list(self.snapshot) + + client = _DynamicClient() + adapter = MCPAdapter(server_name="github", mcp_client=client) + + # First call populates the cache. + adapter.call_tool("alive") + assert adapter.cached_annotations("alive") is not None + assert client.list_calls == 1 + + # Replace the upstream inventory with a new toolset. + # Both ``snapshot`` (what list_tools returns) AND ``_tools`` + # (the underlying call_tool mock) need to stay in sync — + # the mock's call_tool validates against ``_tools``. + new_tool = _Tool( + name="replacement_tool", + annotations=_Ann( + readOnlyHint=False, + destructiveHint=True, + openWorldHint=False, + ), + ) + client.snapshot = [new_tool] + client._tools = {new_tool.name: new_tool} + # Reset the cache to force a refresh — simulates elapsed + # TTL without waiting on wallclock. The adapter exposes + # no public ``invalidate`` method (we'd rather keep the + # public surface tiny), so we poke the private attribute. + adapter._cache = {} + adapter._cached_at = 0.0 + + adapter.call_tool("replacement_tool") + # The new tool name is now in the cache. + cached = adapter.cached_annotations("replacement_tool") + assert cached is not None + assert cached.destructive is True + # The old tool name is no longer present. + assert adapter.cached_annotations("alive") is None + # The upstream was called again — at least twice now + # (initial population + forced refresh). + assert client.list_calls >= 2 + + +def test_call_tool_distinct_server_names_share_no_cache(): + """Two adapters with different ``server_name`` arguments + don't share cached inventory. Pin: ``self._cache`` is + per-instance, so a user with multiple MCP integrations + gets clean separation.""" + + github = _MockMcpClient(_github_inventory()) + filesystem = _MockMcpClient( + [_Tool(name="read_file", annotations=_Ann(readOnlyHint=True))] + ) + a = MCPAdapter(server_name="github", mcp_client=github) + b = MCPAdapter(server_name="filesystem", mcp_client=filesystem) + + a.call_tool("create_issue") + b.call_tool("read_file") + + # Each adapter sees only its own inventory. + assert "create_issue" in a.list_cached_tools() + assert "create_issue" not in b.list_cached_tools() + assert "read_file" in b.list_cached_tools() + assert "read_file" not in a.list_cached_tools() + + +def test_call_tool_idempotent_under_repeated_invocations(): + """Repeated calls on the same tool keep returning the + same metadata. Pins that the contextvar setter does NOT + accumulate state across calls — each + ``set_mcp_tool_context`` call replaces the previous value.""" + + client = _MockMcpClient(_github_inventory()) + adapter = MCPAdapter(server_name="github", mcp_client=client) + for _ in range(5): + adapter.call_tool("create_issue", {"repo": "acme/api"}) + ann = get_call_mcp_annotations() + assert ann["destructive"] is True + # The cache wasn't rebuilt each iteration — same call + # repeated, no upstream polling needed on every + # invocation beyond the first. + # (Exact list_tools call count is _maybe_refresh's + # private concern; this test pins the user-visible + # outcome.) diff --git a/tests/test_mcp_context.py b/tests/test_mcp_context.py new file mode 100644 index 0000000..586496b --- /dev/null +++ b/tests/test_mcp_context.py @@ -0,0 +1,157 @@ +"""Tests for the v3.31 (Разрыв 3) MCP tool-context helpers. + +Pure contextvar plumbing — no network involved. These tests are +the SDK-side contract pin for the wire fields the backend expects: + * ``tool_class`` — one of `builtin | mcp | custom | invalid` + * ``mcp_annotations` — dict with `read_only`, `destructive`, + `open_world` keys, each `bool | None`. + +Pre-fix these helpers did not exist; SDKs had to fake +classifications at the wire level by hand. Post-fix the helpers +provide a single owner for the contextvar lifecycle so v3.31's +honest-SDK trust boundary (McpAnnotations forwarded verbatim from +``tools/list``) is testable. +""" + +import pytest + +import nullrun.context as _ctx +from nullrun.context import ( + get_call_mcp_annotations, + get_call_mcp_class, + set_mcp_tool_context, +) + + +@pytest.fixture(autouse=True) +def _isolate_mcp_context(): + """Reset the module-level ContextVars around every test. + + MCP adapter tests run earlier in the full suite and intentionally + leave their last call metadata in the current context. Reset the + variables directly because ``set_mcp_tool_context(None, None)`` + has partial-update semantics and therefore does not clear them. + """ + _ctx._call_mcp_class_var.set(None) + _ctx._call_mcp_annotations_var.set(None) + yield + _ctx._call_mcp_class_var.set(None) + _ctx._call_mcp_annotations_var.set(None) + + +class TestMcpContext: + def test_class_defaults_to_none(self): + assert get_call_mcp_class() is None + assert get_call_mcp_annotations() is None + + def test_set_class_persists(self): + set_mcp_tool_context(tool_class="mcp") + assert get_call_mcp_class() == "mcp" + # Annotations remain None because we only set class. + assert get_call_mcp_annotations() is None + + def test_set_annotations_persists(self): + set_mcp_tool_context( + annotations={ + "read_only": True, + "destructive": False, + "open_world": True, + } + ) + ann = get_call_mcp_annotations() + assert ann is not None + assert ann["read_only"] is True + assert ann["destructive"] is False + assert ann["open_world"] is True + + def test_set_both_at_once(self): + set_mcp_tool_context( + tool_class="mcp", + annotations={"destructive": True}, + ) + assert get_call_mcp_class() == "mcp" + ann = get_call_mcp_annotations() + assert ann == {"destructive": True} + + def test_partial_updates_dont_drop_unset_side(self): + # Set both, then update only the class. Annotations + # should NOT be cleared — the helper's contract is + # "set what you pass, leave what you don't". + set_mcp_tool_context( + tool_class="mcp", + annotations={"destructive": True}, + ) + set_mcp_tool_context(tool_class="builtin") + assert get_call_mcp_class() == "builtin" + ann = get_call_mcp_annotations() + assert ann == {"destructive": True} + + def test_clearing_class_with_none(self): + """Partial-update contract: explicit ``None`` does NOT + clear a previously-set value. ``set_mcp_tool_context`` is + designed so the caller can update just the fields they + care about (most callers don't want to wipe the + annotations when re-stamping the class). Use + ``set_mcp_tool_context(tool_class='invalid')`` to + reset to a sentinel, or just don't call the helper at + all to inherit the default ``None``.""" + + set_mcp_tool_context( + tool_class="mcp", + annotations={"destructive": True}, + ) + assert get_call_mcp_class() == "mcp" + assert get_call_mcp_annotations() == {"destructive": True} + # Explicit None for one field leaves the other alone — + # partial-update behavior, NOT clear-on-None semantics. + set_mcp_tool_context(tool_class=None) + assert get_call_mcp_class() == "mcp" # unchanged + # To actually clear, set an explicit sentinel value. + + # To get a fresh-None state for the next test, clear via + # the helper that exists for this exact purpose: + import nullrun.context as _ctx + + _ctx._call_mcp_class_var.set(None) # noqa: SLF001 + _ctx._call_mcp_annotations_var.set(None) # noqa: SLF001 + assert get_call_mcp_class() is None + assert get_call_mcp_annotations() is None + + def test_clearing_annotations_with_none(self): + """Partial-update contract (see also + ``test_clearing_class_with_none``): ``set_mcp_tool_context`` + with ``annotations=None`` leaves the previously-set + class alone.""" + + set_mcp_tool_context( + tool_class="mcp", + annotations={"destructive": True}, + ) + assert get_call_mcp_class() == "mcp" + assert get_call_mcp_annotations() == {"destructive": True} + # Passing None for annotations leaves it alone — + # partial-update semantics. + set_mcp_tool_context(annotations=None) + assert get_call_mcp_annotations() == {"destructive": True} + assert get_call_mcp_class() == "mcp" + + def test_annotations_partial_dict_allowed(self): + # Operators may forward only the keys they have. The + # backend treats absent keys as "unknown" rather than + # false (per Разрыв 3 / wire contract). The SDK does + # the same — partial dicts are accepted verbatim. + set_mcp_tool_context(annotations={"destructive": True}) + ann = get_call_mcp_annotations() + assert "destructive" in ann + assert "read_only" not in ann + assert "open_world" not in ann + + +class TestMcpClassValues: + @pytest.mark.parametrize( + "value", + ["builtin", "mcp", "custom", "invalid"], + ) + def test_class_strings_round_trip(self, value): + set_mcp_tool_context(tool_class=value) + assert get_call_mcp_class() == value diff --git a/tests/test_transport.py b/tests/test_transport.py index 044b450..6cab408 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -816,3 +816,126 @@ def test_refetch_does_not_import_requests(self): f"{[m for m in new_modules if 'request' in m.lower()]}). " "B20 regression: the refetch path must use ``self._client``." ) + + +class TestToolArgumentsForwarding: + """Разрыв 4 / T5.6 (2026-07-31) wire-shape pins for + the `tool_arguments` field on the /execute and /check + endpoints. The backend (T5.6) reads `tool_arguments` + from the request, computes a schema fingerprint, and + UPSERTs into `mcp_tool_signatures` on every + authenticated MCP /check. + + Pre-T5.6 SDKs (≤ 0.14.4) never set this field; the + backend falls back to the Разрыв 2 `tool_params` + field. The wire change is additive-only. + """ + + @respx.mock + def test_execute_forwards_tool_arguments_to_wire(self, transport): + """`tool_arguments` is included on the wire when + the caller passes a non-None value.""" + captured: dict = {} + + def capture(request: httpx.Request) -> httpx.Response: + import json + captured.update(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "decision": "allow", + "policy_id": "policy-123", + "policy_version": 5, + "explanation": "allowed", + }, + ) + + respx.post("https://api.test.nullrun.io/api/v1/execute").mock( + side_effect=capture + ) + result = transport.execute( + organization_id="ws-123", + execution_id="exec-456", + trace_id="trace-789", + tool="mcp://github/create_issue", + input_data={}, + tool_arguments={"repo": "acme/api", "title": "fix"}, + ) + assert result["decision"] == "allow" + # Wire contract: the JSON body must contain + # `tool_arguments` with the exact payload the + # caller passed. Field name, not nested under + # `input` or `details`. + assert "tool_arguments" in captured + assert captured["tool_arguments"] == { + "repo": "acme/api", + "title": "fix", + } + + @respx.mock + def test_execute_omits_tool_arguments_when_none(self, transport): + """Default `tool_arguments=None` MUST NOT appear + on the wire. Legacy SDKs (≤ 0.14.4) round-trip + cleanly because the field is absent. + """ + captured: dict = {} + + def capture(request: httpx.Request) -> httpx.Response: + import json + captured.update(json.loads(request.content)) + return httpx.Response( + 200, + json={"decision": "allow", "policy_id": "p", "policy_version": 1}, + ) + + respx.post("https://api.test.nullrun.io/api/v1/execute").mock( + side_effect=capture + ) + transport.execute( + organization_id="ws-123", + execution_id="exec-456", + trace_id="trace-789", + tool="mcp://github/create_issue", + input_data={}, + ) + # `tool_arguments` MUST be absent when caller + # didn't pass it. The wire change is additive- + # only; pre-T5.6 SDKs never wrote the key. + assert "tool_arguments" not in captured + + @respx.mock + def test_check_forwards_tool_arguments_via_check_request(self, transport): + """The /check (gate) path forwards + `tool_arguments` from `check_request` dict. + Mirrors the contract on /execute; same field + name, same shape.""" + captured: dict = {} + + def capture(request: httpx.Request) -> httpx.Response: + import json + captured.update(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "decision": "allow", + "policy_id": "policy-123", + "policy_version": 5, + "explanation": "allowed", + }, + ) + + respx.post("https://api.test.nullrun.io/api/v1/gate").mock( + side_effect=capture + ) + result = transport.check( + check_request={ + "organization_id": "ws-123", + "execution_id": "exec-456", + "tool": "mcp://github/create_issue", + "tool_arguments": {"repo": "acme/api"}, + } + ) + assert result["decision"] == "allow" + # Wire contract: tool_arguments round-trips + # verbatim from check_request → wire JSON. + assert captured.get("tool_arguments") == {"repo": "acme/api"}