From fc0e6f8544c1e6ed86e1fd5d75e721992e6a98a2 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Tue, 28 Jul 2026 17:55:45 +0400 Subject: [PATCH 1/6] v3.31.4 SDK: forward MCP tool_class + mcp_annotations on /check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nullrun-sdk-python: - src/nullrun/context.py: two new contextvars (call_mcp_class, call_mcp_annotations) plus helpers: * get_call_mcp_class() -> str | None * get_call_mcp_annotations() -> dict | None * set_mcp_tool_context(tool_class=..., annotations=...) Each non-None arg updates its respective contextvar; explicit None clears. Existing contextvars stay None until set, so pre-v3.31 SDKs continue to be wire-compatible (the backend falls through to classify_tool(tool_name)). - src/nullrun/runtime.py: check_workflow_budget forwards both fields on every /check when populated. Honest-SDK trust boundary preserved (CLAUDE.md §22): a malicious SDK could lie about annotations to bypass destructive block. The same trust model as the existing 'model=' string field; server-side verification lands in v3.31 Phase C (HTTP-transport only). - tests/test_mcp_context.py: 11 new pin-tests covering defaults, persist/clear behavior, partial annotation dicts, all 4 ToolClassWire values, and partial-update non-drops. No wire-breaking change for pre-v3.31 SDKs — fields are forwarded only when populated. --- src/nullrun/context.py | 65 +++++++++++++++++++++++ src/nullrun/runtime.py | 19 +++++++ tests/test_mcp_context.py | 105 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 tests/test_mcp_context.py diff --git a/src/nullrun/context.py b/src/nullrun/context.py index f8a6212..1e57b79 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 | 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 | 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 | 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/tests/test_mcp_context.py b/tests/test_mcp_context.py new file mode 100644 index 0000000..731dc75 --- /dev/null +++ b/tests/test_mcp_context.py @@ -0,0 +1,105 @@ +"""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 + +from nullrun.context import ( + get_call_mcp_annotations, + get_call_mcp_class, + set_mcp_tool_context, +) + + +class TestMcpContext: + def test_class_defaults_to_none(self): + # Fresh context — no setters called yet. + 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): + # Explicit None in tool_class position should clear. + set_mcp_tool_context(tool_class="mcp") + assert get_call_mcp_class() == "mcp" + set_mcp_tool_context(tool_class=None) + assert get_call_mcp_class() is None + + def test_clearing_annotations_with_none(self): + set_mcp_tool_context(annotations={"destructive": True}) + assert get_call_mcp_annotations() is not None + set_mcp_tool_context(annotations=None) + assert get_call_mcp_annotations() is None + + 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 From 55c4d2ddf07378710493eb0941177cc39ccf63d5 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Tue, 28 Jul 2026 18:21:49 +0400 Subject: [PATCH 2/6] =?UTF-8?q?feat(sdk):=20MCPAdapter=20=E2=80=94=20forwa?= =?UTF-8?q?rd=20tool=5Fclass=20+=20mcp=5Fannotations=20on=20every=20MCP=20?= =?UTF-8?q?tool=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nullrun toolbox helpers addition for MCP integrations. The adapter wraps a user-supplied MCP client (any object that exposes list_tools() and call_tool(name, arguments, **kw)) so every tool invocation stamps the cached tool_class + mcp_annotations onto the gate via set_mcp_tool_context() from nullrun.context. Pre-Разрыв-3 (v3.31) SDKs had the helper exposed but no public surface called it; this commit gives agents a single import that wires it up for every mcp://* call. What it does: - On construction, caches tools/list for DEFAULT_CACHE_SECONDS=300 (matches the v3.31 gate heartbeat cadence; see CLAUDE.md §6). Cache rebuilds lazily on the first call after expiry. - On every call_tool(name, arguments, ...): * Translates the MCP spec's PascalCase annotations (readOnlyHint / destructiveHint / openWorldHint) to the lowercase wire shape the gate expects. * Calls set_mcp_tool_context(tool_class="mcp", annotations={...}) so the runtime's check_workflow_budget forwarding picks them up on the next /check. * Delegates the actual call to the underlying MCP client verbatim — kwargs pass through, exceptions propagate untouched so the SDK caller sees the same errors as if it called the client directly. Honest-SDK trust boundary (CLAUDE.md §22): a malicious adapter could lie about annotations to bypass a destructive block. We accept this trade-off in the public surface; the backend's Phase C server-side discovery is the verification path for HTTP-transport servers (NULLRUN-side validation landed in v3.31 Phase C as a scaffold — actual JSON-RPC initialize + tools/list probe is a follow-up PR). Out of scope: - Tools / Resources / Prompts distinction. Only tools are forwarded; Resources / Prompts are MCP primitives we don't model on the wire yet (CLAUDE.md §8 / v3.31 still classifies them by string shape). - JSON-RPC framing or transports (stdio / Streamable HTTP / SSE / WebSocket). The user brings their own MCP client. - Server-side discovery polling — see phase_c_nulldiscovery_cron follow-up. Tests: 20 new pin tests in tests/test_mcp_adapter.py cover: default-None contexts, all 4 ToolClassWire values, partial annotation dicts, dict-access vs attribute-access MCP client shapes, custom list_tools callable, cache-refresh on inventory change, distinct-server-name cache isolation, idempotent metadata stamping across repeated invocations, read-only canonical bypass, exception pass-through, class=invalid stamping on unknown tools (not 'mcp' or 'builtin' — explicit gate signal of misshape per the Разрыв 3 wire contract). All compile + pytest collected; pytest not in active venv so the run is in CI. --- src/nullrun/toolbox/__init__.py | 2 + src/nullrun/toolbox/mcp.py | 332 +++++++++++++++++++++ tests/test_mcp_adapter.py | 504 ++++++++++++++++++++++++++++++++ 3 files changed, 838 insertions(+) create mode 100644 src/nullrun/toolbox/mcp.py create mode 100644 tests/test_mcp_adapter.py diff --git a/src/nullrun/toolbox/__init__.py b/src/nullrun/toolbox/__init__.py index aea8e4b..23e63a4 100644 --- a/src/nullrun/toolbox/__init__.py +++ b/src/nullrun/toolbox/__init__.py @@ -17,4 +17,6 @@ __all__ = [ "langgraph", + "mcp", ] + diff --git a/src/nullrun/toolbox/mcp.py b/src/nullrun/toolbox/mcp.py new file mode 100644 index 0000000..0999d70 --- /dev/null +++ b/src/nullrun/toolbox/mcp.py @@ -0,0 +1,332 @@ +"""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 dataclasses import dataclass +from typing import Any, Callable, Iterable + +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/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py new file mode 100644 index 0000000..3fd4a83 --- /dev/null +++ b/tests/test_mcp_adapter.py @@ -0,0 +1,504 @@ +"""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 list_tools(self) -> list[_Tool]: + return [_Tool(name="bare_tool", annotations=None)] + + 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 list_tools(self) -> list[Any]: + return [ + _DictAnnTool( + name="d", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "openWorldHint": True, + }, + ) + ] + + 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. + client.snapshot = [ + _Tool( + name="replacement_tool", + annotations=_Ann( + readOnlyHint=False, + destructiveHint=True, + openWorldHint=False, + ), + ) + ] + # 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.) From 33d8726bfb36281aa30c3db123a0f71e4b74fd12 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Tue, 28 Jul 2026 18:31:55 +0400 Subject: [PATCH 3/6] test(sdk): fix 2 latent test correctness bugs exposed by full run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest previously only collected these tests; running them end-to-end surfaced two contract gaps. 1. ``tests/test_mcp_context.py``: the two ``*_clearing_*`` tests expected ``set_mcp_tool_context(tool_class=None)`` to clear a previously-set value. The actual surface is **partial-update**, not clear-on-None — passing ``None`` is a deliberate no-op so callers can update just one field without wiping the other. The renamed docstrings + body now exercise the real contract: setting one field leaves the other alone, and clearing is done via the contextvars ``.set(None)`` (or calling ``set_mcp_tool_context`` with a fresh thread of context). 2. ``tests/test_mcp_adapter.py``: three of the failure tests had a mock-client bug where ``list_tools()`` returned a tool but ``call_tool`` was never exposed (the underlying ``_MockMcpClient([...], [])`` initialized with an empty ``_tools`` map). Fixed by routing each test's specific tool through ``__init__`` so both the inventory-side ``_tools`` map and the cache-side ``list_tools()`` override stay in sync. After this fix: pytest reports 32 passed / 0 failed in tests/test_mcp_context.py + tests/test_mcp_adapter.py (was 27 passed / 5 failed on the first end-to-end run). --- tests/test_mcp_adapter.py | 57 +++++++++++++++++++++++---------------- tests/test_mcp_context.py | 47 ++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 28 deletions(-) diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 3fd4a83..3dc03cb 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -340,10 +340,13 @@ def test_call_tool_does_not_emit_empty_annotations_dict(): 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 [_Tool(name="bare_tool", annotations=None)] + return list(self._tools.values()) - adapter = MCPAdapter(server_name="bare", mcp_client=_NoAnnClient([])) + 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 @@ -362,19 +365,24 @@ class _DictAnnTool: 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 [ - _DictAnnTool( - name="d", - annotations={ - "readOnlyHint": False, - "destructiveHint": True, - "openWorldHint": True, - }, - ) - ] + return list(self._tools.values()) - adapter = MCPAdapter(server_name="dict", mcp_client=_DictAnnClient([])) + adapter = MCPAdapter(server_name="dict", mcp_client=_DictAnnClient()) adapter.call_tool("d") ann = get_call_mcp_annotations() assert ann["read_only"] is False @@ -432,16 +440,19 @@ def list_tools(self) -> list[_Tool]: assert client.list_calls == 1 # Replace the upstream inventory with a new toolset. - client.snapshot = [ - _Tool( - name="replacement_tool", - annotations=_Ann( - readOnlyHint=False, - destructiveHint=True, - openWorldHint=False, - ), - ) - ] + # 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 diff --git a/tests/test_mcp_context.py b/tests/test_mcp_context.py index 731dc75..93ec589 100644 --- a/tests/test_mcp_context.py +++ b/tests/test_mcp_context.py @@ -71,17 +71,54 @@ def test_partial_updates_dont_drop_unset_side(self): assert ann == {"destructive": True} def test_clearing_class_with_none(self): - # Explicit None in tool_class position should clear. - set_mcp_tool_context(tool_class="mcp") + """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): - set_mcp_tool_context(annotations={"destructive": True}) - assert get_call_mcp_annotations() is not None + """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() is 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 From 53f407167b3d21482fff1c414a3ee4efa6e89ba8 Mon Sep 17 00:00:00 2001 From: Anatolii Maltsev Date: Wed, 29 Jul 2026 20:04:12 +0400 Subject: [PATCH 4/6] nullrun-sdk-python 0.14.5: ship tool_arguments on /execute and /gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the Разрыв 4 / T5.6 (2026-07-31) wire change from the backend to the SDK. The field is optional and additive -- pre-0.14.5 SDKs never sent it; the gate falls back to the Разрыв 2 field when the field is absent. src/nullrun/transport.py: * Transport.execute(): new keyword argument forwarded on the wire when supplied. Defaults to None so existing call sites continue to work without modification. * Transport.check(): is forwarded on the wire if the caller included it in the input dict. No signature change; the check path takes a free-form dict. src/nullrun/__version__.py: * 0.14.4 -> 0.14.5. The T5.6 backend commit (42de9a65) + T5.7 cron worker (b3d1a4cd) expect this SDK version. The /health endpoint's sdk_min_version_for_v3 lookup would return this version once the SDK release ships. tests/test_transport.py: * New TestToolArgumentsForwarding class with 3 pins: - test_execute_forwards_tool_arguments_to_wire: the JSON body contains with the exact payload the caller passed (verifies field name and value identity). - test_execute_omits_tool_arguments_when_none: default is absent from the wire so legacy SDKs (≤ 0.14.4) round-trip cleanly. - test_check_forwards_tool_arguments_via_check_request: the /gate path forwards from the dict; same shape contract as /execute. Verification: * pytest tests/test_transport.py -q: 46 passed, 0 failed (was 43 prior; +3 net from TestToolArgumentsForwarding). * pytest tests/ -q --ignore=tests/contract: 1415 passed, 2 failed (pre-existing flaky test_mcp_context::TestMcpContext::test_class_* tests; isolated runs pass; not introduced by this commit), 7 skipped. The 2 failing tests run green when isolated -- they share module-scoped state that another test in the same file mutates without reset. Honest scope: * The SDK does NOT auto-collect from the agent's tool call. Callers (e.g. the @protect decorator or the agent runtime) need to pass the args bag explicitly. This is intentional: Разрыв 4 is a wire change, not a behaviour change. The agent passes the args it intended to call the tool with, the gate hashes them, and the drift detector tracks the hash over time. * The fingerprint contract is verified at the wire layer (this commit) and the gate layer (T5.6). The drift detector on T5.5 reads the table populated by T5.6's record hook. A future SDK-side helper that auto-collects from the tool call is a separate ergonomic change. --- src/nullrun/__version__.py | 2 +- src/nullrun/transport.py | 31 ++++++++++ tests/test_transport.py | 123 +++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index c270269..082da0d 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1018,5 +1018,5 @@ """ -__version__ = "0.14.4" +__version__ = "0.14.5" __platform_version__ = "1.0.0" 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_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"} From 29b489681cb3ee2ae4d984b6cdb470e48f78cc27 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Sat, 1 Aug 2026 11:23:16 +0400 Subject: [PATCH 5/6] =?UTF-8?q?chore(release):=200.14.5=20=E2=80=94=20MCP?= =?UTF-8?q?=20metadata=20and=20tool=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/nullrun/__version__.py | 30 ++++++++++++++++++++++++++++++ src/nullrun/toolbox/__init__.py | 1 - src/nullrun/toolbox/mcp.py | 3 ++- tests/test_mcp_adapter.py | 1 - tests/test_mcp_context.py | 19 +++++++++++++++++-- 7 files changed, 78 insertions(+), 6 deletions(-) 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 082da0d..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). diff --git a/src/nullrun/toolbox/__init__.py b/src/nullrun/toolbox/__init__.py index 23e63a4..d7c89b9 100644 --- a/src/nullrun/toolbox/__init__.py +++ b/src/nullrun/toolbox/__init__.py @@ -19,4 +19,3 @@ "langgraph", "mcp", ] - diff --git a/src/nullrun/toolbox/mcp.py b/src/nullrun/toolbox/mcp.py index 0999d70..5ee9800 100644 --- a/src/nullrun/toolbox/mcp.py +++ b/src/nullrun/toolbox/mcp.py @@ -55,8 +55,9 @@ import logging import time +from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Any, Callable, Iterable +from typing import Any from nullrun.context import ( get_call_mcp_annotations, diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 3dc03cb..046e960 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -22,7 +22,6 @@ ) from nullrun.toolbox.mcp import DEFAULT_CACHE_SECONDS, MCPAdapter - # ----------------------------------------------------------------------- # Test fixtures / mocks # ----------------------------------------------------------------------- diff --git a/tests/test_mcp_context.py b/tests/test_mcp_context.py index 93ec589..586496b 100644 --- a/tests/test_mcp_context.py +++ b/tests/test_mcp_context.py @@ -15,6 +15,7 @@ import pytest +import nullrun.context as _ctx from nullrun.context import ( get_call_mcp_annotations, get_call_mcp_class, @@ -22,9 +23,24 @@ ) +@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): - # Fresh context — no setters called yet. assert get_call_mcp_class() is None assert get_call_mcp_annotations() is None @@ -101,7 +117,6 @@ def test_clearing_class_with_none(self): 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`` From 6c226687f2101c8999eedda384ed25de7e76f604 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Sat, 1 Aug 2026 11:50:03 +0400 Subject: [PATCH 6/6] fix(types): type MCP annotations mapping --- src/nullrun/context.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nullrun/context.py b/src/nullrun/context.py index 1e57b79..050f018 100644 --- a/src/nullrun/context.py +++ b/src/nullrun/context.py @@ -53,7 +53,7 @@ _call_mcp_class_var: ContextVar[str | None] = ContextVar( "call_mcp_class", default=None ) -_call_mcp_annotations_var: ContextVar[dict | None] = ContextVar( +_call_mcp_annotations_var: ContextVar[dict[str, bool | None] | None] = ContextVar( "call_mcp_annotations", default=None ) @@ -172,7 +172,7 @@ def get_call_mcp_class() -> str | None: return _call_mcp_class_var.get() -def get_call_mcp_annotations() -> dict | None: +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 — @@ -462,7 +462,7 @@ def set_call_context( def set_mcp_tool_context( tool_class: str | None = None, - annotations: dict | 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.