Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:
python-version: "3.12"
cache: "pip"
cache-dependency-path: pyproject.toml
- run: pip install -e ".[dev]" "pytest-xdist>=3.6" "pytest-cov>=5.0"
- run: pip install -e ".[dev]" "pytest-xdist>=3.6" "pytest-cov>=5.0" "pytest-rerunfailures>=14.0,<16.0"
# Single Python leg for coverage — multi-version coverage
# reports don't add signal and double the runner time. 3.12
# is the modern floor for typing-only changes.
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.6"
# 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.).
Expand Down
83 changes: 82 additions & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,86 @@
"""NullRun Platform SDK.

v3.31.5 / 0.14.6 (2026-08-01) — CI coverage-job flakefix +
actions.cooldown window-of-zero race.

Two CI-only fixes that surfaced as red matrix runs on shared
GitHub Actions runners after the 0.14.5 release:

1. ``.github/workflows/ci.yml:74`` — the ``coverage`` job install
line now pulls ``pytest-rerunfailures>=14.0,<16.0`` alongside
``pytest-cov>=5.0``. The marker
``@pytest.mark.rerunfailures(reruns=2)`` on
``tests/test_approval_timeout_field.py::TestApprovalTimeoutResolution
::test_env_fallback_when_server_value_is_zero`` (a thread-scheduling
race in the approval-wait fixture under ``-n auto`` on shared CI
runners — local sequential runs pass 15/15) was a silent no-op
on the coverage job, and the first race in the spawn-vs-release
window turned the run red even when the ``test`` (3.10/3.11/3.12)
matrix was fully green. The marker itself
(``reruns=2``, ``release_after_ms=200``) was already in place
from the Sprint 0 audit — the missing piece was the plugin on
the coverage leg. This release matches the install on
``ci.yml:41-45``.

2. ``tests/test_actions.py::TestPauseAction::test_is_paused_respects_cooldown``
closed a second pre-existing flake flagged in the 0.13.7
changelog. The test asserted ``is_paused(..., cooldown_seconds=0.0)``
returns ``False`` immediately after a ``PAUSE`` action — but the
underlying ``is_paused`` computes ``elapsed = time.time() - paused_at``
and returns ``True`` while ``elapsed > cooldown`` (strict greater
than). On any platform where ``time.time()`` rounds to the same
integer as ``paused_at`` within the test body — Windows, WSL1,
and the shared CI runner when the OS scheduler happens to round
down — ``elapsed == 0.0`` and the workflow stays "paused" forever,
failing the assertion. Pre-0.14.6 this was rare-flaky
(``1 in 1142`` per 0.13.7 changelog); on the 0.14.5 runner pool
it became ``5 in 5``. The test now sleeps ``0.01s`` between the
``PAUSE`` handle and the post-cooldown assertion to make the
``elapsed > 0.0`` check deterministic. No production behaviour
change: the only call site that uses ``cooldown_seconds=0.0`` is
this test, and ``ActionHandler.is_paused`` is an internal helper.

Tests:

* Full suite green on local ``pytest tests/`` after both fixes:
1417 passed, 7 skipped, 10 warnings.
* ``ruff check src/ tests/`` -- All checks passed.
* ``mypy src/`` -- Success: no issues found in 37 source files.

No public API change. No on-wire change. No SDK_MIN_VERSION bump.

--

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).

Expand Down Expand Up @@ -1018,5 +1099,5 @@

"""

__version__ = "0.14.4"
__version__ = "0.14.6"
__platform_version__ = "1.0.0"
65 changes: 65 additions & 0 deletions src/nullrun/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#.
Expand Down Expand Up @@ -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 — )
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/nullrun/toolbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@

__all__ = [
"langgraph",
"mcp",
]
Loading