From a733e2e38cc9d98d4e72cc1f59ddb3419dd2023f Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Thu, 10 Sep 2026 21:06:02 +0530 Subject: [PATCH 01/16] Add GitHub Actions to bump versions on main and publish from the release PR. Keep pyproject.toml and uv.lock in sync, skip a bump while a version is still unpublished, and only hit the PyPI environment when an upload will actually run. --- .github/workflows/bump-version.yml | 85 +++++++++++++++ .github/workflows/publish.yml | 64 ++++++++++++ scripts/bump_version.py | 159 +++++++++++++++++++++++++++++ tests/test_bump_version.py | 68 ++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 .github/workflows/bump-version.yml create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/bump_version.py create mode 100644 tests/test_bump_version.py diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..3f7ff55 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,85 @@ +name: Bump version + +# After a non-release PR merges to main, open (or refresh) a release PR that +# bumps pyproject.toml + uv.lock ahead of PyPI. Merging that PR runs publish.yml. +on: + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + bump: + description: Semver component to increment + type: choice + options: [patch, minor, major] + default: patch + +concurrency: + group: bump-version + cancel-in-progress: true + +jobs: + bump: + if: > + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + !contains(github.event.pull_request.labels.*.name, 'release')) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.merge_commit_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Plan and write bump + id: bump + run: | + set -euo pipefail + KIND="${{ github.event_name == 'workflow_dispatch' && inputs.bump || 'patch' }}" + VERSION=$(python scripts/bump_version.py --bump "$KIND" --write | tail -n 1) + if [ -z "$VERSION" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Open or update release PR + if: steps.bump.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="release/${VERSION}" + git checkout -B "$BRANCH" + git add pyproject.toml uv.lock + git diff --staged --quiet && { echo "No version files changed"; exit 1; } + git commit -m "chore(release): ${VERSION}" + git push --force-with-lease origin "HEAD:refs/heads/${BRANCH}" + gh label create release --description "Version bump / PyPI release" --color 0E8A16 --force || true + EXISTING=$(gh pr list --base main --head "$BRANCH" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Updated PR #${EXISTING}" + exit 0 + fi + gh pr create --base main --head "$BRANCH" --title "chore(release): ${VERSION}" --label release --body "$(cat <> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "pyproject version is already on PyPI; skipping upload" + fi + + publish: + needs: check + if: needs.check.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/nitrostack/ + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -U build && python -m build + - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Tag release + env: + VERSION: ${{ needs.check.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + TAG="v${VERSION}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + exit 0 + fi + git tag -a "$TAG" -m "nitrostack ${VERSION}" + git push origin "$TAG" diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..4b466ae --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Bump and sync the SDK version in pyproject.toml and uv.lock. + +Version sources of truth: + - pyproject.toml [project].version + - uv.lock [[package]] name = \"nitrostack\" (editable .) + +Template app versions (0.1.0) and README CLI examples are not the SDK release. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from typing import Optional, Sequence, Tuple + +PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" +_PROJECT_VERSION = re.compile(r'(?m)^(version = ")([^"]+)(")') +_LOCK_NITROSTACK = re.compile( + r'(name = "nitrostack"\nversion = ")([^"]+)("\nsource = \{ editable = "\." \})', +) + + +def parse_x_y_z(raw: str) -> Tuple[int, int, int]: + parts = (raw or "").strip().split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + raise ValueError(f"Expected X.Y.Z version, got {raw!r}") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def bump_x_y_z(raw: str, kind: str) -> str: + major, minor, patch = parse_x_y_z(raw) + if kind == "major": + return f"{major + 1}.0.0" + if kind == "minor": + return f"{major}.{minor + 1}.0" + if kind == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"Unknown bump kind {kind!r}") + + +def version_key(raw: str) -> Tuple[int, int, int]: + return parse_x_y_z(raw) + + +def read_project_version(pyproject_text: str) -> str: + match = _PROJECT_VERSION.search(pyproject_text) + if not match: + raise ValueError("pyproject.toml has no version = \"...\" field") + return match.group(2) + + +def set_project_version(pyproject_text: str, version: str) -> str: + updated, n = _PROJECT_VERSION.subn(rf"\g<1>{version}\3", pyproject_text, count=1) + if n != 1: + raise ValueError("Could not replace [project].version in pyproject.toml") + return updated + + +def set_lock_nitrostack_version(lock_text: str, version: str) -> str: + updated, n = _LOCK_NITROSTACK.subn(rf"\g<1>{version}\3", lock_text, count=1) + if n != 1: + raise ValueError("Could not replace editable nitrostack version in uv.lock") + return updated + + +def fetch_pypi(url: str = PYPI_JSON) -> dict: + with urllib.request.urlopen(url, timeout=30) as response: + return json.load(response) + + +def pypi_latest_and_released(payload: dict) -> Tuple[Optional[str], set]: + info = payload.get("info") or {} + latest = info.get("version") + released = set((payload.get("releases") or {}).keys()) + return latest, released + + +def plan_bump(git_ver: str, latest: Optional[str], released: set, kind: str) -> Optional[str]: + """Return the next version to write, or None if a bump PR is not needed. + + Unpublished git versions are left for the publish workflow (git not on PyPI). + Otherwise bump from max(git, PyPI latest) so a stale git version cannot + collide with a manual upload (e.g. git 0.3.2, PyPI 0.3.5 → 0.3.6). + """ + if git_ver not in released: + return None + base = git_ver + if latest: + base = git_ver if version_key(git_ver) >= version_key(latest) else latest + return bump_x_y_z(base, kind) + + +def should_publish(git_ver: str, released: set) -> bool: + return git_ver not in released + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def _write(path: str, text: str) -> None: + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + + +def apply_version(root: str, version: str) -> None: + pyproject = f"{root}/pyproject.toml" + lock = f"{root}/uv.lock" + _write(pyproject, set_project_version(_read(pyproject), version)) + _write(lock, set_lock_nitrostack_version(_read(lock), version)) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--bump", choices=("patch", "minor", "major"), default="patch") + parser.add_argument( + "--write", + action="store_true", + help="Write pyproject.toml and uv.lock. Prints the new version.", + ) + parser.add_argument( + "--if-unpublished", + action="store_true", + help="Exit 0 if the git version is not on PyPI (publish it); else exit 1.", + ) + args = parser.parse_args(argv) + + git_ver = read_project_version(_read(f"{args.root}/pyproject.toml")) + payload = fetch_pypi() + latest, released = pypi_latest_and_released(payload) + + if args.if_unpublished: + if should_publish(git_ver, released): + print(git_ver) + return 0 + print(f"skip: {git_ver} is already on PyPI", file=sys.stderr) + return 1 + + planned = plan_bump(git_ver, latest, released, args.bump) + if planned is None: + print( + f"skip: {git_ver} is not on PyPI yet (publish this version first)", + file=sys.stderr, + ) + return 0 + if args.write: + apply_version(args.root, planned) + print(planned) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 0000000..ffa7bb7 --- /dev/null +++ b/tests/test_bump_version.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib.util +import os + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SCRIPT = os.path.join(ROOT, "scripts", "bump_version.py") + + +def _load(): + spec = importlib.util.spec_from_file_location("bump_version", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +bump = _load() + + +def test_bump_x_y_z(): + assert bump.bump_x_y_z("0.3.5", "patch") == "0.3.6" + assert bump.bump_x_y_z("0.3.5", "minor") == "0.4.0" + assert bump.bump_x_y_z("0.3.5", "major") == "1.0.0" + + +def test_plan_bump_skips_unpublished_git_version(): + assert bump.plan_bump("0.3.6", "0.3.5", {"0.3.5"}, "patch") is None + + +def test_plan_bump_from_pypi_when_git_is_stale(): + released = {"0.3.2", "0.3.4", "0.3.5"} + assert bump.plan_bump("0.3.2", "0.3.5", released, "patch") == "0.3.6" + + +def test_plan_bump_patch_when_git_matches_pypi(): + assert bump.plan_bump("0.3.5", "0.3.5", {"0.3.5"}, "patch") == "0.3.6" + + +def test_should_publish(): + assert bump.should_publish("0.3.6", {"0.3.5"}) is True + assert bump.should_publish("0.3.5", {"0.3.5"}) is False + + +def test_apply_version_syncs_pyproject_and_lock(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "nitrostack"\nversion = "0.3.2"\n', + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + '[[package]]\nname = "annotated-types"\nversion = "0.8.0"\n\n' + '[[package]]\nname = "nitrostack"\nversion = "0.3.2"\n' + 'source = { editable = "." }\n', + encoding="utf-8", + ) + bump.apply_version(str(tmp_path), "0.3.6") + pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + lock = (tmp_path / "uv.lock").read_text(encoding="utf-8") + assert 'version = "0.3.6"' in pyproject + assert 'name = "nitrostack"\nversion = "0.3.6"\nsource = { editable = "." }' in lock + assert 'name = "annotated-types"\nversion = "0.8.0"' in lock + + +def test_set_project_version_only_first_field(): + text = '[project]\nversion = "0.3.2"\n\n[tool.ruff]\ntarget-version = "py310"\n' + assert bump.read_project_version(bump.set_project_version(text, "0.3.6")) == "0.3.6" From 5340e7f34702deb3e4f0b4f52652709629f5954f Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 2 Oct 2026 03:22:09 +0530 Subject: [PATCH 02/16] version-bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5b4a3b3..f370a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitrostack" -version = "0.3.2" +version = "0.4.0" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" From df5456f441f878ac36c7d81336eb09a8442ea5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aykut=20G=C3=BCney?= <50778230+KeuqS@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:29:50 +0300 Subject: [PATCH 03/16] docs: document widget data and CSP handling --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index 6074a49..c1ea808 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,57 @@ from nitrostack import WidgetOptions, WidgetCsp, widget `nitrostack-py init` copies `widgets/out/{route}.html` for every `@widget`. Widget HTML is generated in Python from the tool's `structuredContent` (one iframe, N cards). +#### Custom widget HTML: data and host bridge + +The tool result is passed to the widget as a JSON-compatible object. The shared host bridge +normalizes the following sources, in order: + +1. `window.openai.toolOutput.structuredContent` (or `toolResult`). +2. A JSON `application/json` content block, or a JSON object in a `text/plain` content block. +3. The `#nitrostack-tool-data` embedded JSON script used by Python-rendered pages. + +The normalized object is passed to `window.__nitroWidgetRender(data)`. Keep the tool return +shape stable and render from that object. `NITROSTACK_APP_MODE` changes the advertised +metadata and resource MIME type, not this data shape: + +- `openai` uses OpenAI output-template metadata and the OpenAI host data channel. +- `mcp-app` uses MCP Apps `_meta.ui` metadata; tool-result notifications are normalized by the bridge. +- `universal` (the default) publishes both forms. + +A hand-written file found at `widgets/out/{route}.html` is loaded as-is. The loader does +not inject the host bridge into that file. The built-in route templates created by `init` +do include the bridge, so custom HTML must include the equivalent bridge/render hook itself. +The generated files under `examples/widgets/out/` are the reference implementation. + +Minimal render hook (used after the bridge has been included): + +```html +
Waiting for tool data…
+ +``` + +#### Widget CSP metadata + +`WidgetCsp` describes the origins a widget needs; NitroStack publishes this as +metadata for the host to enforce. The SDK does not build or attach a response +`Content-Security-Policy` header itself. + +| Field | Meaning | MCP Apps metadata | OpenAI metadata | +|---|---|---|---| +| `connect_domains` | Network connections made by the widget | `connectDomains` | `connect_domains` | +| `resource_domains` | External resources such as images, scripts, or styles | `resourceDomains` | `resource_domains` | +| `frame_domains` | Origins the widget may embed in frames | `frameDomains` | `frame_domains` | + +Whether a scheme such as `data:` is accepted is host-dependent. Listing `data:` +in `resource_domains` requests/advertises that resource origin; it is not a guarantee +that every host will allow data URIs. + MCP Inspector: use **HTTP + stateless**, then the **Apps** tab. `tools/call` also embeds the data-filled HTML. Do not use `widgets/preview.html` as the live result — that file is a static helper. Live preview: `http://localhost:3000/widgets/preview`. From 421ae5f42bbc05fe7412f932a4ae76df07072373 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 05:26:54 +0530 Subject: [PATCH 04/16] ConfigService: process environment wins over .env .env values were written over os.environ unconditionally, so the PORT=3000 that `nitrostack init` puts in .env overrode the PORT a host passes when starting the server. NitroStudio starts a second project on 3001; it bound 3000 anyway and died with "address already in use" (NITSTD-222). Keys already in the process environment are now kept, as with python-dotenv and Node's dotenv. .env still fills keys the process doesn't set, and defaults stay lowest. Co-Authored-By: Claude Opus 5.5 --- nitrostack/auth/config.py | 7 ++++++- tests/test_auth_modules.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/nitrostack/auth/config.py b/nitrostack/auth/config.py index f97c56d..7b4cd4c 100644 --- a/nitrostack/auth/config.py +++ b/nitrostack/auth/config.py @@ -19,7 +19,10 @@ def __init__( # Merge system environment variables first self._config.update(os.environ) - # Parse .env manually (no stdout prints allowed) + # Parse .env manually (no stdout prints allowed). The process + # environment wins, as with python-dotenv and Node's dotenv: a host + # that starts the server with PORT=3001 must not be overridden by the + # PORT=3000 that `nitrostack init` writes into .env. if not ignore_env_file and os.path.exists(env_file_path): try: with open(env_file_path, "r", encoding="utf-8") as f: @@ -31,6 +34,8 @@ def __init__( k, v = line.split("=", 1) k = k.strip() v = v.strip().strip("'\"") # Remove wrapping quotes + if k in os.environ: + continue self._config[k] = v os.environ[k] = v except Exception as e: diff --git a/tests/test_auth_modules.py b/tests/test_auth_modules.py index cd19843..8030511 100644 --- a/tests/test_auth_modules.py +++ b/tests/test_auth_modules.py @@ -178,3 +178,33 @@ def test_api_key_empty_string_is_rejected(monkeypatch): service = DIContainer.get_instance().resolve(ApiKeyService) assert service.validate("") is False assert service.validate("anything") is False + + +def test_config_process_env_wins_over_env_file(tmp_path, monkeypatch): + from nitrostack.core.app import resolve_http_port + + env_file = tmp_path / ".env" + env_file.write_text("PORT=3000\nFROM_FILE=file\n", encoding="utf-8") + monkeypatch.setenv("PORT", "3001") + monkeypatch.delenv("FROM_FILE", raising=False) + ConfigModule.for_root(env_file_path=str(env_file), defaults={"PORT": "9999", "ONLY_DEFAULT": "d"}) + cfg = DIContainer.get_instance().resolve(ConfigService) + assert cfg.get("PORT") == "3001" + assert resolve_http_port() == 3001 + # Keys the process doesn't set still come from the file, then defaults. + assert cfg.get("FROM_FILE") == "file" + assert cfg.get("ONLY_DEFAULT") == "d" + monkeypatch.delenv("FROM_FILE", raising=False) + + +def test_config_env_file_port_applies_when_process_has_none(tmp_path, monkeypatch): + from nitrostack.core.app import resolve_http_port + + env_file = tmp_path / ".env" + env_file.write_text("PORT=3005\n", encoding="utf-8") + monkeypatch.delenv("PORT", raising=False) + ConfigModule.for_root(env_file_path=str(env_file), defaults={"PORT": "3000"}) + cfg = DIContainer.get_instance().resolve(ConfigService) + assert cfg.get("PORT") == "3005" + assert resolve_http_port() == 3005 + monkeypatch.delenv("PORT", raising=False) From 57e8d35e85f4922c875e5e21fad173ea2f20cb7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:04:04 +0000 Subject: [PATCH 05/16] chore(release): 0.4.1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f370a58..68ea96b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitrostack" -version = "0.4.0" +version = "0.4.1" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index ed009ff..bab7717 100644 --- a/uv.lock +++ b/uv.lock @@ -568,7 +568,7 @@ wheels = [ [[package]] name = "nitrostack" -version = "0.3.2" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "anyio" }, From 0e145d4bc8c71cefa293a2e643d0a8d42ea3746a Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 05:55:41 +0530 Subject: [PATCH 06/16] Add scripts/release.sh for releasing to PyPI by hand publish.yml only uploads through PyPI trusted publishing, which has never run here, and the Bump version workflow can't open its release PR (the org doesn't allow GitHub Actions to create pull requests). The script covers both: optionally makes release/ from origin/main (--bump), checks the branch matches origin, the lockfile matches and the version is new, runs the tests, builds, twine-checks, uploads after asking, verifies a clean install from PyPI, and opens the release PR. `--tag` tags origin/main after the merge. `--dry-run` stops before anything leaves the machine. Co-Authored-By: Claude Opus 5.5 --- scripts/release.sh | 235 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 scripts/release.sh diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..c12c846 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Release the SDK to PyPI from this machine. +# +# publish.yml uploads only through PyPI trusted publishing, and the Bump +# version workflow can't open its release PR (the org doesn't let GitHub +# Actions create pull requests). This script does both by hand. +# +# 1. Get a release branch, either way: +# - the Bump version workflow already pushed `release/X.Y.Z`: +# git switch release/X.Y.Z && git pull +# - or have this script make one from origin/main: +# scripts/release.sh --bump patch (or minor / major) +# 2. On that branch: scripts/release.sh +# checks, tests, builds, uploads to PyPI (asks first), verifies the +# install, and opens the release PR if there isn't one. +# 3. Merge the release PR (publish.yml sees the version on PyPI and skips). +# 4. scripts/release.sh --tag tags origin/main vX.Y.Z and pushes the tag. +# +# Options: +# --bump KIND patch|minor|major: create release/ from origin/main +# --skip-tests don't run the test suite before building +# --yes don't ask before uploading +# --dry-run stop before anything leaves this machine (no push, upload, +# PR or tag); --bump still creates the branch locally +# --tag step 4 only +# +# Needs uv, gh (for the PR) and a PyPI token, in ~/.pypirc ([pypi] with +# username __token__) or in TWINE_USERNAME=__token__ / TWINE_PASSWORD. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BUMP="" +SKIP_TESTS=0 +YES=0 +DRY_RUN=0 +TAG_ONLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --bump) + BUMP="${2:-}" + shift + ;; + --skip-tests) SKIP_TESTS=1 ;; + --yes) YES=1 ;; + --dry-run) DRY_RUN=1 ;; + --tag) TAG_ONLY=1 ;; + -h | --help) + sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1 (see --help)" >&2 + exit 2 + ;; + esac + shift +done +case "$BUMP" in "" | patch | minor | major) ;; *) + echo "--bump takes patch, minor or major" >&2 + exit 2 + ;; +esac + +step() { printf '\n==> %s\n' "$*"; } +fail() { + printf '\nerror: %s\n' "$*" >&2 + exit 1 +} + +# uv: a pyenv shim fails inside this repo when the pinned .python-version +# isn't installed in pyenv, so fall back to known install locations. +find_uv() { + local candidate + for candidate in "${UV:-}" "$(command -v uv 2>/dev/null || true)" \ + "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv" /opt/homebrew/bin/uv /usr/local/bin/uv \ + "$HOME"/.pyenv/versions/*/bin/uv; do + if [ -n "$candidate" ] && [ -x "$candidate" ] && "$candidate" --version >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + return 1 +} +UV="$(find_uv)" || fail "uv not found. Install it (https://docs.astral.sh/uv/) or set UV=/path/to/uv." +UVX="$(dirname "$UV")/uvx" +[ -x "$UVX" ] || UVX="$UV tool run" +PY=("$UV" run --no-project --quiet python) + +project_version() { "${PY[@]}" -c 'import re,sys; print(re.search(r"(?m)^version = \"([^\"]+)\"", sys.stdin.read()).group(1))'; } +# Exit 0 when VERSION is on PyPI. +on_pypi() { curl -fs -o /dev/null "https://pypi.org/pypi/nitrostack/$1/json"; } +tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null || [ -n "$(git ls-remote --tags origin "refs/tags/v$1")" ]; } + +step "Fetching origin" +git fetch -q --tags origin + +# --- Step 4: tag the merged release ------------------------------------- +if [ "$TAG_ONLY" = 1 ]; then + VERSION="$(git show origin/main:pyproject.toml | project_version)" + echo "origin/main is at $VERSION" + on_pypi "$VERSION" || fail "$VERSION isn't on PyPI. Release it first (scripts/release.sh on its release branch)." + tag_exists "$VERSION" && fail "Tag v$VERSION already exists." + if [ "$DRY_RUN" = 1 ]; then + echo "Dry run: would tag origin/main ($(git rev-parse --short origin/main)) as v$VERSION and push it." + exit 0 + fi + git tag -a "v$VERSION" origin/main -m "nitrostack $VERSION" + git push -q origin "v$VERSION" + echo "Tagged and pushed v$VERSION." + exit 0 +fi + +[ -z "$(git status --porcelain --untracked-files=no)" ] || fail "Uncommitted changes. Commit or stash them first." + +# --- Step 1 (optional): make the release branch ------------------------- +if [ -n "$BUMP" ]; then + step "Creating a $BUMP release branch from origin/main" + MAIN_VERSION="$(git show origin/main:pyproject.toml | project_version)" + on_pypi "$MAIN_VERSION" || fail "origin/main's version $MAIN_VERSION isn't on PyPI yet. Release it as is (no --bump)." + # bump_version plans from the checked-out tree, so check out origin/main; + # go back to where we were if anything below fails. + ORIG_REF="$(git branch --show-current)" + ORIG_REF="${ORIG_REF:-$(git rev-parse HEAD)}" + trap 'git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF"' ERR + git switch -q --detach origin/main + NEXT="$("${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" | tail -n 1)" + [ -n "$NEXT" ] || { + git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF" + fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." + } + git rev-parse -q --verify "refs/heads/release/$NEXT" >/dev/null && fail "Branch release/$NEXT already exists locally." + [ -z "$(git ls-remote --heads origin "release/$NEXT")" ] || fail "release/$NEXT already exists on origin. Use it: git switch release/$NEXT" + git switch -q -c "release/$NEXT" + "${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" --write >/dev/null + git add pyproject.toml uv.lock + git commit -q -m "chore(release): $NEXT" + trap - ERR + echo "Created release/$NEXT with the version bump." + if [ "$DRY_RUN" = 0 ]; then + git push -q -u origin "release/$NEXT" + fi +fi + +# --- Step 2: check, test, build, upload --------------------------------- +BRANCH="$(git branch --show-current)" +[ -n "$BRANCH" ] || fail "Not on a branch. Switch to the release branch first." +VERSION="$(project_version /dev/null; then + installed="$("$VENV/bin/python" -c 'import importlib.metadata as m, nitrostack; print(m.version("nitrostack"))')" + break + fi + sleep 10 +done +rm -rf "$(dirname "$VENV")" +[ "$installed" = "$VERSION" ] || fail "Couldn't install and import nitrostack==$VERSION from PyPI." +echo "OK: pip install nitrostack==$VERSION works and imports." + +# --- Release PR --------------------------------------------------------- +step "Release PR" +if ! command -v gh >/dev/null; then + echo "gh not found. Open a PR from $BRANCH to main, labelled 'release'." +else + PR="$(gh pr list --base main --head "$BRANCH" --json url --jq '.[0].url // empty')" + if [ -z "$PR" ]; then + PR="$(gh pr create --base main --head "$BRANCH" --title "chore(release): $VERSION" --label release --body "$( + cat < Date: Fri, 16 Oct 2026 06:00:10 +0530 Subject: [PATCH 07/16] release.sh: review fixes - Run from a temp copy: --bump switches branches, which can rewrite the script while bash is still reading it. - --bump plans from origin/main's files in a scratch folder and checks everything before switching, so a failed check no longer leaves you on a detached origin/main. - Only a 404 counts as "not on PyPI"; offline or PyPI errors stop the run instead of passing the "version is new" check. - No terminal and no --yes means "no" instead of a silent exit. - Tag the uploaded commit right after the upload (no separate step that could tag later, unreleased merges on main). --tag stays for catching up untagged releases and prefers the release branch head. - A failed PR creation after the upload prints the manual step. Co-Authored-By: Claude Opus 5.5 --- scripts/release.sh | 82 +++++++++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index c12c846..8042f2f 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -12,9 +12,8 @@ # scripts/release.sh --bump patch (or minor / major) # 2. On that branch: scripts/release.sh # checks, tests, builds, uploads to PyPI (asks first), verifies the -# install, and opens the release PR if there isn't one. +# install, tags the uploaded commit vX.Y.Z, and opens the release PR. # 3. Merge the release PR (publish.yml sees the version on PyPI and skips). -# 4. scripts/release.sh --tag tags origin/main vX.Y.Z and pushes the tag. # # Options: # --bump KIND patch|minor|major: create release/ from origin/main @@ -22,13 +21,24 @@ # --yes don't ask before uploading # --dry-run stop before anything leaves this machine (no push, upload, # PR or tag); --bump still creates the branch locally -# --tag step 4 only +# --tag only tag a version that's already on PyPI but untagged +# (the head of origin/release/X.Y.Z if it still exists, +# else origin/main) # # Needs uv, gh (for the PR) and a PyPI token, in ~/.pypirc ([pypi] with # username __token__) or in TWINE_USERNAME=__token__ / TWINE_PASSWORD. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# bash reads a script while running it, and --bump switches branches, which +# can rewrite this file. Run from a copy. +if [ -z "${NITRO_RELEASE_ROOT:-}" ]; then + NITRO_RELEASE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + copy="$(mktemp)" + cp "${BASH_SOURCE[0]}" "$copy" + NITRO_RELEASE_ROOT="$NITRO_RELEASE_ROOT" NITRO_RELEASE_COPY="$copy" exec bash "$copy" "$@" +fi +trap 'rm -f "${NITRO_RELEASE_COPY:-}"' EXIT +ROOT="$NITRO_RELEASE_ROOT" cd "$ROOT" BUMP="" @@ -47,7 +57,7 @@ while [ $# -gt 0 ]; do --dry-run) DRY_RUN=1 ;; --tag) TAG_ONLY=1 ;; -h | --help) - sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -89,8 +99,17 @@ UVX="$(dirname "$UV")/uvx" PY=("$UV" run --no-project --quiet python) project_version() { "${PY[@]}" -c 'import re,sys; print(re.search(r"(?m)^version = \"([^\"]+)\"", sys.stdin.read()).group(1))'; } -# Exit 0 when VERSION is on PyPI. -on_pypi() { curl -fs -o /dev/null "https://pypi.org/pypi/nitrostack/$1/json"; } +# Exit 0 when VERSION is on PyPI, 1 when it isn't; stop on any other answer +# (offline, PyPI down) rather than guess. +on_pypi() { + local code + code="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/nitrostack/$1/json" || true)" + case "$code" in + 200) return 0 ;; + 404) return 1 ;; + *) fail "Couldn't check PyPI for $1 (HTTP ${code:-none}). Check your connection and try again." ;; + esac +} tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null || [ -n "$(git ls-remote --tags origin "refs/tags/v$1")" ]; } step "Fetching origin" @@ -102,11 +121,17 @@ if [ "$TAG_ONLY" = 1 ]; then echo "origin/main is at $VERSION" on_pypi "$VERSION" || fail "$VERSION isn't on PyPI. Release it first (scripts/release.sh on its release branch)." tag_exists "$VERSION" && fail "Tag v$VERSION already exists." + # Tag what was uploaded: the release branch head, when it's still around. + TARGET=origin/main + if git rev-parse -q --verify "refs/remotes/origin/release/$VERSION" >/dev/null && + [ "$(git show "origin/release/$VERSION:pyproject.toml" | project_version)" = "$VERSION" ]; then + TARGET="origin/release/$VERSION" + fi if [ "$DRY_RUN" = 1 ]; then - echo "Dry run: would tag origin/main ($(git rev-parse --short origin/main)) as v$VERSION and push it." + echo "Dry run: would tag $TARGET ($(git rev-parse --short "$TARGET")) as v$VERSION and push it." exit 0 fi - git tag -a "v$VERSION" origin/main -m "nitrostack $VERSION" + git tag -a "v$VERSION" "$TARGET" -m "nitrostack $VERSION" git push -q origin "v$VERSION" echo "Tagged and pushed v$VERSION." exit 0 @@ -119,24 +144,23 @@ if [ -n "$BUMP" ]; then step "Creating a $BUMP release branch from origin/main" MAIN_VERSION="$(git show origin/main:pyproject.toml | project_version)" on_pypi "$MAIN_VERSION" || fail "origin/main's version $MAIN_VERSION isn't on PyPI yet. Release it as is (no --bump)." - # bump_version plans from the checked-out tree, so check out origin/main; - # go back to where we were if anything below fails. - ORIG_REF="$(git branch --show-current)" - ORIG_REF="${ORIG_REF:-$(git rev-parse HEAD)}" - trap 'git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF"' ERR - git switch -q --detach origin/main - NEXT="$("${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" | tail -n 1)" - [ -n "$NEXT" ] || { - git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF" - fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." - } + # Plan from origin/main's files (and its bump_version.py) in a scratch + # folder, so nothing changes here until every check has passed. + PLAN="$(mktemp -d)" + for f in pyproject.toml uv.lock scripts/bump_version.py; do + mkdir -p "$PLAN/$(dirname "$f")" + git show "origin/main:$f" >"$PLAN/$f" + done + NEXT="$("${PY[@]}" "$PLAN/scripts/bump_version.py" --root "$PLAN" --bump "$BUMP" | tail -n 1)" + [ -n "$NEXT" ] || fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." git rev-parse -q --verify "refs/heads/release/$NEXT" >/dev/null && fail "Branch release/$NEXT already exists locally." [ -z "$(git ls-remote --heads origin "release/$NEXT")" ] || fail "release/$NEXT already exists on origin. Use it: git switch release/$NEXT" - git switch -q -c "release/$NEXT" - "${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" --write >/dev/null + "${PY[@]}" "$PLAN/scripts/bump_version.py" --root "$PLAN" --bump "$BUMP" --write >/dev/null + git switch -q -c "release/$NEXT" origin/main + cp "$PLAN/pyproject.toml" "$PLAN/uv.lock" . + rm -rf "$PLAN" git add pyproject.toml uv.lock git commit -q -m "chore(release): $NEXT" - trap - ERR echo "Created release/$NEXT with the version bump." if [ "$DRY_RUN" = 0 ]; then git push -q -u origin "release/$NEXT" @@ -186,11 +210,15 @@ fi step "Uploading nitrostack $VERSION to PyPI" if [ "$YES" = 0 ]; then - read -r -p "Upload nitrostack $VERSION to PyPI? A version can't be uploaded twice. [y/N] " answer + read -r -p "Upload nitrostack $VERSION to PyPI? A version can't be uploaded twice. [y/N] " answer || answer="" case "$answer" in y | Y | yes) ;; *) fail "Not uploaded." ;; esac fi $UVX twine upload --non-interactive "$WHEEL" "$SDIST" +# Tag the exact commit that was built and uploaded. +git tag -a "v$VERSION" HEAD -m "nitrostack $VERSION" +git push -q origin "v$VERSION" || echo "warning: couldn't push tag v$VERSION. Push it yourself: git push origin v$VERSION" >&2 + step "Checking the release on PyPI" for _ in $(seq 1 30); do on_pypi "$VERSION" && break @@ -224,12 +252,12 @@ else - Bump SDK version to **$VERSION** in \`pyproject.toml\` and \`uv.lock\`. - $VERSION is already on PyPI (uploaded by \`scripts/release.sh\`), so \`publish.yml\` will skip the upload when this merges. -After merging, tag it: \`scripts/release.sh --tag\` +Tagged \`v$VERSION\` on the uploaded commit. EOF - )")" + )")" || PR="Couldn't open the PR. Open one from $BRANCH to main, labelled 'release'." fi echo "$PR" fi echo -echo "Released nitrostack $VERSION. Next: merge the release PR, then run scripts/release.sh --tag" +echo "Released nitrostack $VERSION (tag v$VERSION). Next: merge the release PR." From b7257e8601782087ab600df883e4747e5fed8001 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 06:10:31 +0530 Subject: [PATCH 08/16] docs: refresh README for the current SDK; add PyPI project links The README is the PyPI project page. Rewrite it for 0.4.x: building blocks (tools, resources, prompts, guards, auth, caching, rate limits, tasks, events, health), env precedence, templates, clients. tests/test_readme_examples.py compiles every python block, checks every nitrostack import exists, and runs the Quick Start and Building Blocks examples through the testing harness so the page can't drift. Co-Authored-By: Claude Opus 5.5 --- README.md | 528 +++++++++++++++++++++++++--------- pyproject.toml | 6 + tests/test_readme_examples.py | 148 ++++++++++ 3 files changed, 541 insertions(+), 141 deletions(-) create mode 100644 tests/test_readme_examples.py diff --git a/README.md b/README.md index 6074a49..66b02cd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # NitroStack Python SDK -A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) framework, enabling NestJS-like modular architecture, dependency injection, execution pipelines, background task processing, built-in authentication modules, and a diagnostic testing harness. +Build Model Context Protocol (MCP) servers in Python with a NestJS-style structure: modules, dependency injection, decorators for tools, resources and prompts, and a pipeline of guards, middleware, interceptors, pipes and exception filters. It's the Python port of the [NitroStack](https://docs.nitrostack.ai/) TypeScript framework. + +- **Docs:** [docs.nitrostack.ai/python](https://docs.nitrostack.ai/python) +- **Source and issues:** [github.com/nitrocloudofficial/nitrostack-python-sdk](https://github.com/nitrocloudofficial/nitrostack-python-sdk) +- **Requires:** Python 3.10+ --- ## Features -- **Nested Modular Architecture**: Group components cleanly with `@module`. -- **Dependency Injection**: Explicit class constructor DI with `DIContainer` and `@injectable(deps=[...])`. -- **Pipeline Interceptors**: Build guards, middleware, interceptors, pipes, and exception filters for tool execution. -- **Asynchronous Background Tasks**: Spawn background workers automatically for long-running tools. -- **Built-in Authentication**: Modules for API Keys, JWT verification, and OAuth 2.1 (featuring Protected Resource Metadata discovery servers). -- **In-Process Testing Harness**: Run unit and integration tests against modules without managing subprocesses or real network transports. -- **CLI Tooling (`nitrostack-py`)**: Scaffold apps (`init`), generate components (`generate`), pack deployable wheels (`pack`), upgrade/install dependencies, validate projects, auto-register servers with Claude (`register`), and run hot-reload development servers (`dev`). +- **Modules and dependency injection**: group code with `@module`, inject services with `@injectable(deps=[...])`. +- **Tools, resources and prompts**: plain decorated methods, with Pydantic input/output schemas, tool annotations and examples. +- **Execution pipeline**: guards, middleware, interceptors, pipes and exception filters per tool. +- **Auth**: API keys, JWT, and OAuth 2.1 (protected resource metadata, token introspection or JWKS, PKCE, CIMD, scopes). +- **Long-running tasks**: tools that report progress and can be cancelled, over the MCP tasks API. +- **Widgets**: attach an HTML UI to a tool for ChatGPT Apps and MCP Apps clients. +- **Transports**: stdio, Streamable HTTP (plus legacy SSE), or both at once; 2025 and 2026 protocol versions. +- **Caching, rate limits, events and health checks** as decorators. +- **Testing harness**: call tools, resources and prompts in-process, without a transport. +- **CLI (`nitrostack-py`)**: scaffold, generate code, run with hot reload, validate, pack wheels, upgrade. +- **NitroStudio**: open a project folder in [NitroStudio](https://nitrostack.ai/) to run, chat with and debug it. --- @@ -20,41 +28,43 @@ A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) frame ```bash pip install nitrostack +# or +uv add nitrostack ``` -To install local developer or test dependencies: +To work on the SDK itself (tests and tooling): + ```bash -pip install -e . +pip install -e ".[dev]" ``` --- ## Scaffolding a New Project (Recommended) -You can quickly scaffold a new project template using the interactive CLI tool: - ```bash -nitrostack-py init +nitrostack-py init my-server --template python-starter ``` *(Or via Python: `python -m nitrostack.cli.main init`)* -The project name is optional on the command line. If omitted, the CLI asks for it next: +Both arguments are optional; without them `init` asks: -```bash -nitrostack-py init my-server --template python-starter -``` +1. **Project name**. Default: `my-mcp-server`. +2. **Template**: + + | Template | What it shows | + |---|---| + | `python-starter` | A calculator: tools, a widget, a health check. | + | `python-pizzaz` | A pizza shop finder with map and list widgets. | + | `python-oauth` | Flight booking with OAuth 2.1 and scope-guarded tools (see `OAUTH_SETUP.md` in the project). | -This launches an interactive prompt where you can: -1. **Project name** (if not passed as an argument). Default: `my-mcp-server`. -2. **Choose a template** by explicit name: - - **python-starter**: A simple calculator server. - - **python-pizzaz**: A pizza shop finder with maps and widgets. - - **python-oauth**: A flight booking server demonstrating OAuth 2.1 authentication and guarded routes. -3. **Provide metadata**: Specify a custom description and author name. -4. **Install dependencies**: `Install dependencies: (Y/n)` — Enter or `Y` runs `npm install` in `src/widgets`; `n` skips it. `--skip-install` skips the prompt. +3. **Description and author**. +4. **Install dependencies (Y/n)**: installs the Python dependencies (`uv sync` when `uv` is on PATH, otherwise `.venv` + `pip`), then runs `npm install` in `src/widgets` if the template has one. `--skip-install` skips this. -Optional port flags override the defaults (**3000** MCP, **3001** widgets): +`init` also installs agent skills for this SDK (from [skills-python-sdk](https://github.com/nitrocloudofficial/skills-python-sdk)) into the project's coding-agent folders. `--force` overwrites existing skill folders. + +Port flags override the defaults (**3000** for MCP, **3001** for widgets): ```bash nitrostack-py init my-server --template python-starter --port 4000 --widget 4001 @@ -62,86 +72,9 @@ nitrostack-py dev --port 4000 --widget 4001 nitrostack-py start --port 4000 --widget 4001 ``` -`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies` — that breaks the cloud wheel metadata. - -Once scaffolded, follow the next steps printed by the CLI to run your server, configure environment variables, and try it out. - ---- - -## CLI (`nitrostack-py`) - -The CLI is installed with the SDK (`nitrostack-py`, or `python -m nitrostack.cli.main`). Run `nitrostack-py --help` to list commands. - -### Project lifecycle - -```bash -nitrostack-py init my-server -nitrostack-py dev # hot-reload development server -nitrostack-py start # production server (no reload) -nitrostack-py register --name my-mcp-server --file app.py -``` - -### Generate components - -Existing `tool` and `module` generators are unchanged. Additional generators create pipeline and service stubs that follow the current Python decorator/protocol APIs: - -```bash -nitrostack-py generate tool add_numbers -nitrostack-py generate module payments -nitrostack-py generate guard MyGuard -nitrostack-py generate pipe Validation -nitrostack-py generate interceptor Transform -nitrostack-py generate filter HttpException -nitrostack-py generate service Email -``` - -Generated files: - -| Command | Output | -|---|---| -| `generate tool ` | `{name}_tool.py` in the current directory | -| `generate module ` | `{name}_module.py` in the current directory | -| `generate guard ` | `guards/.py` | -| `generate pipe ` | `pipes/.py` | -| `generate interceptor ` | `interceptors/.py` | -| `generate filter ` | `filters/.py` | -| `generate service ` | `services/.py` | - -Attach generated pipeline classes with `@use_guards`, `@use_pipes`, `@use_interceptors`, or `@use_filters`. Register services in a module's `providers` list. - -### Pack a deployable wheel - -```bash -nitrostack-py pack --dry-run # list files; does not write an artifact -nitrostack-py pack # write dist/*.whl -``` - -`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. The real `.env` file and other secrets are never packed. Temporary build directories are deleted afterwards. - -### Upgrade, install, validate - -```bash -nitrostack-py upgrade # latest nitrostack on PyPI (writes nitrostack>=latest) -nitrostack-py upgrade --version 0.3.2 # pin exactly this version (writes nitrostack==0.3.2) -nitrostack-py upgrade --dry-run # print the change; do not edit files -nitrostack-py upgrade --allow-downgrade --version 0.1.0 # required to pin an older version - -nitrostack-py install # install project + development dependencies -nitrostack-py install --production # skip optional extras and requirements-dev.txt - -nitrostack-py validate # lint deps, @mcp_app imports, and @module() refs -``` - -`init` writes `pyproject.toml`, `.python-version`, and `uv.toml`, then runs `uv lock` when `uv` is on PATH. `install` prefers `uv sync` in that case. If `requirements.txt` pins nitrostack as a local path (`-e /path/to/nitrostack-python-sdk`), install uses `pip install -r requirements.txt` instead so unpublished SDK testing still works. The uv equivalent is: +`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. Because the SDK is vendored, `nitrostack-py upgrade` (below) is how an existing project picks up a new SDK release. -```toml -[tool.uv.sources] -nitrostack = { path = "/path/to/nitrostack-python-sdk", editable = true } -``` - -then `uv lock` / `uv sync`. Without `uv`, install falls back to `.venv` + `pip`. - -`upgrade` updates the `nitrostack` dependency spec in `pyproject.toml` in place (and `requirements.txt` when it already pins nitrostack). `--version X` writes `nitrostack==X`. Without `--version`, the latest PyPI release is written as `nitrostack>=latest`. A target older than the currently declared version is rejected unless `--allow-downgrade` is passed. `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes. +Once scaffolded, follow the next steps the CLI prints. --- @@ -231,9 +164,13 @@ if __name__ == "__main__": asyncio.run(main()) ``` +Run it with `python app.py` (stdio), or `MCP_TRANSPORT_TYPE=http python app.py` for Streamable HTTP on `http://127.0.0.1:3000/mcp`. + +Scaffolded projects split the same pieces into files: `main.py` calls `McpApplicationFactory.create(AppModule)`, `app_module.py` holds the root module, and each feature lives in `modules//`. When the server's name is generic (`app` or `mcp-server`, as with a root `@module(name="app")` and no `@mcp_app`), the name and description reported to clients come from `SERVER_NAME` / `SERVER_DESC`. + ### 2. Configure Environment Variables -The SDK reads standard settings from the environment or `.env` files: +The SDK reads settings from the process environment and the project's `.env` file. **The process environment wins:** a variable already set when the server starts (for example `PORT=3001` from NitroStudio or your shell) is not overwritten by `.env`. `.env` fills in what isn't set, and `ConfigModule.for_root(defaults=...)` fills in the rest. (Before 0.4.1, `.env` overwrote the environment.) | Environment Variable | Description | |---|---| @@ -241,6 +178,7 @@ The SDK reads standard settings from the environment or `.env` files: | `TRUSTED_PROXIES` / `MCP_TRUSTED_PROXIES` | Comma-separated IPs, CIDRs, or hostnames allowed to send `X-Forwarded-Host` / `X-Forwarded-Proto`. Unset means those headers are ignored. `X-Forwarded-For` is never used to decide trust. | | `PORT` / `MCP_SERVER_PORT` | The port to bind for HTTP/SSE transport (default: `3000`). Overridden by `nitrostack-py --port`. | | `WIDGETS_DEV_PORT` | Widget Next.js port (default: `3001`). Overridden by `nitrostack-py --widget`. | +| `SERVER_NAME` / `SERVER_DESC` | Server name and description reported to clients when the configured name is generic (`app`, `mcp-server`). `init` sets `SERVER_NAME` to the project folder name. | | `MCP_TRANSPORT_TYPE` | Transport selection: `stdio`, `http`, or `dual` (combining stdio + HTTP/SSE). | | `NODE_ENV` | If set to `production`, defaults to `dual` transport. Otherwise defaults to `stdio`. | | `MCP_MAX_SESSIONS` | Cap on concurrent Streamable HTTP sessions; new sessions beyond the cap get an HTTP `429`. Unset = unlimited. | @@ -249,11 +187,241 @@ The SDK reads standard settings from the environment or `.env` files: | `NITRO_MCP_PROTOCOL_VERSION` | Protocol era (case-insensitive): `auto` / `both` / `dual` / `dual-spec` (default when unset or unknown), `modern` / `latest` / `2026` / `2026-07-28`, or `legacy` / `2025` / `2025-06-18` / `2025-11-25`. `auto` is not the same as `modern`. Wins over `ServerConfig.protocol_era`. | | `MCP_STATELESS` | Explicit override: `true` forces `modern` (stateless HTTP), `false` forces `legacy` (sessionful). Wins over `NITRO_MCP_PROTOCOL_VERSION` and `ServerConfig.protocol_era`. | | `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | Comma-separated allow-lists for DNS-rebinding protection, used only when CORS is disabled. | +| `NITROSTACK_APP_MODE` | Widget metadata flavour: `universal` (default), `openai`, or `mcp-app`. See [Widgets](#widgets-ui-tools). | +| `OAUTH_REQUIRED` | With `OAuthGuard`: `true` rejects calls without a valid token. Unset lets token-less calls through, for local development. | | `NITROSTACK_LOG_FILE` | Destination file for logs (default: `nitrostack.log`). | | `NITROSTACK_LOG_LEVEL` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | | `NITROSTACK_LOG_TO_STDOUT` | Set to `true` to allow logging to stdout under stdio transport (Caution: may corrupt protocol stream). | | `NITROSTACK_HTTP_DEBUG` | Set to `1` to log every HTTP request (method, path, headers, body) and response status to stderr. Use when diagnosing a client that fails to connect: uvicorn's access log shows neither headers nor timestamps. | +Read your own settings through `ConfigService`: + +```python +from nitrostack import ConfigModule, ConfigService, injectable, module + +@injectable(deps=[ConfigService]) +class WeatherService: + def __init__(self, config: ConfigService): + self.units = config.get("WEATHER_UNITS", "metric") + self.api_key = config.get_or_throw("WEATHER_API_KEY") # KeyError if unset + +@module( + name="app", + imports=[ConfigModule.for_root(env_file_path=".env", defaults={"WEATHER_UNITS": "metric"})], + providers=[WeatherService], +) +class AppModule: + pass +``` + +--- + +## Building Blocks + +The examples below build on each other (later ones reuse `LookupInput` and `NoInput`) and are methods on a controller (an `@injectable` class listed in a module's `controllers`). Every tool takes `(self, input, context)`, where `input` is an instance of its `input_schema` model and `context` is the `ExecutionContext` (logger, auth, task, request metadata). + +### Tools + +```python +from pydantic import BaseModel, Field +from nitrostack import tool, initial_tool, ToolAnnotations, ToolExamples, ExecutionContext + +class LookupInput(BaseModel): + city: str = Field(description="City name") + +class Weather(BaseModel): + city: str + temp: float + units: str + +class NoInput(BaseModel): + pass + +class WeatherController: + @tool( + name="get_weather", + title="Get Weather", + description="Current weather for a city", + input_schema=LookupInput, + output_schema=Weather, # optional; adds structuredContent + annotations=ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True), + examples=ToolExamples(input={"city": "Paris"}, output={"city": "Paris", "temp": 21, "units": "metric"}), + ) + async def get_weather(self, input: LookupInput, context: ExecutionContext) -> dict: + return {"city": input.city, "temp": 21, "units": "metric"} + + @tool(name="whats_new", description="Shown when a client connects", input_schema=NoInput) + @initial_tool # clients that support it call this on connect + async def whats_new(self, input: NoInput, context: ExecutionContext) -> str: + return "Try get_weather." +``` + +A tool with no arguments still needs an input model; use an empty `BaseModel` subclass like `NoInput` (not `BaseModel` itself). `visibility="hidden"` keeps a tool callable but out of `tools/list`. Raise an exception to return an MCP error to the client. + +### Resources and prompts + +```python +from typing import List +from nitrostack import resource, prompt, PromptArgument, PromptMessage, ExecutionContext + +class WeatherController: + @resource(uri="weather://units", name="Units", description="Units in use", mime_type="text/plain") + async def units(self, context: ExecutionContext) -> str: + return "metric" + + @prompt( + name="forecast_brief", + description="Write a short forecast", + arguments=[PromptArgument(name="city", description="City", required=True)], + ) + async def forecast_brief(self, args: dict, context: ExecutionContext) -> List[PromptMessage]: + return [PromptMessage(role="user", content=f"Write a short forecast for {args['city']}.")] +``` + +### Guards, interceptors, pipes, middleware and filters + +Each is a small class with one async method; attach it with a decorator below `@tool`. Generate stubs with `nitrostack-py generate guard|pipe|interceptor|filter `. + +| Stage | Method | Attach with | Runs | +|---|---|---|---| +| Guard | `can_activate(context) -> bool` | `@use_guards(...)` | first; `False` rejects the call ("Access denied by guard") | +| Pipe | `transform(value, metadata)` | `@use_pipes(...)` | on the tool input, after guards | +| Middleware | `use(context, next_fn)` | `@use_middleware(...)` | around interceptors and the tool | +| Interceptor | `intercept(context, next_fn)` | `@use_interceptors(...)` | around the tool; can change its result | +| Exception filter | `catch(error, context)` | `@use_filters(...)` | on errors; its return value becomes the result | + +```python +from nitrostack import tool, use_guards, use_interceptors, use_filters, ExecutionContext + +class OfficeHoursGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + return context.metadata.get("office_open", True) + +class TimingInterceptor: + async def intercept(self, context: ExecutionContext, next_fn): + result = await next_fn() + context.logger.info(f"{context.tool_name} finished") + return result + +class FriendlyErrors: + async def catch(self, error: Exception, context: ExecutionContext): + return {"error": str(error)} + +class WeatherController: + @tool(name="get_forecast", description="Forecast for a city", input_schema=LookupInput) + @use_guards(OfficeHoursGuard) + @use_interceptors(TimingInterceptor) + @use_filters(FriendlyErrors) + async def get_forecast(self, input: LookupInput, context: ExecutionContext) -> dict: + return {"city": input.city, "forecast": "sunny"} +``` + +### Authentication + +Register an auth module in the root module, then guard tools. Guards fill `context.auth` (`subject`, `scopes`, `client_id`, `claims`, ...). + +| Module | Guard | Configured by | +|---|---|---| +| `ApiKeyModule.for_root(keys_env_prefix="API_KEY", hashed=False)` | `ApiKeyGuard` | `x-api-key` header checked against `API_KEY`, `API_KEY_1`, `API_KEY_2`, ... (SHA-256 hashes when `hashed=True`) | +| `JWTModule.for_root(secret_env_var="JWT_SECRET", audience=..., issuer=...)` | `JwtGuard` | `Authorization: Bearer ` | +| `OAuthModule.for_root(resource_uri=..., authorization_servers=[...], scopes_supported=[...], token_introspection_endpoint=... or jwks_uri=...)` | `OAuthGuard` | OAuth 2.1 access tokens, audience-bound; `OAUTH_REQUIRED=true` to require one | + +```python +from nitrostack import ( + ApiKeyGuard, ApiKeyModule, ExecutionContext, OAuthGuard, module, require_scopes, tool, use_guards, +) + +class AccountController: + @tool(name="get_balance", description="Account balance", input_schema=NoInput) + @use_guards(ApiKeyGuard) + async def get_balance(self, input: NoInput, context: ExecutionContext) -> dict: + return {"balance": 42} + + @tool(name="close_account", description="Close the account", input_schema=NoInput) + @use_guards(OAuthGuard) + @require_scopes("admin") # needs context.auth with every listed scope + async def close_account(self, input: NoInput, context: ExecutionContext) -> dict: + return {"closed": True} + +@module(name="app", imports=[ApiKeyModule.for_root()], controllers=[AccountController]) +class AppModule: + pass +``` + +Register `ApiKeyModule` when you use `ApiKeyGuard`: without it, the guard falls back to the `API_KEY` variable, and with neither set it lets every call through. + +`has_scope`, `has_any_scope` and `has_all_scopes` check `context.auth` inside a tool. For OAuth, start from the `python-oauth` template and its `OAUTH_SETUP.md`; PKCE (`generate_pkce_params`, `verify_pkce`), client ID metadata documents (`resolve_cimd`) and `generate_www_authenticate_header` are exported for custom flows. + +### Caching and rate limits + +```python +from nitrostack import cache, injectable, rate_limit, tool + +@injectable() +class WeatherService: + @cache(ttl=60) # seconds; keyed on the arguments + async def lookup(self, city: str) -> dict: + return {"city": city, "temp": 21} + +class WeatherController: + @tool(name="ping", description="Ping", input_schema=NoInput) + @rate_limit(max=10, window=60) # 10 calls per 60 s, across all callers + async def ping(self, input: NoInput, context: ExecutionContext) -> str: + return "pong" +``` + +Put `@cache` on a service method with plain, hashable arguments (strings, numbers, tuples). It can't key on a tool's Pydantic input object. + +### Long-running tasks + +Set `task_support="optional"` (or `"required"`) and the client can run the tool as an MCP task: it polls `tasks/get`, can cancel it, and gets live `notifications/progress` when it sends a `progressToken`. `context.task` is `None` when the tool is called normally. + +```python +import asyncio +from pydantic import BaseModel +from nitrostack import tool, ExecutionContext + +class ReportInput(BaseModel): + steps: int = 5 + +class ReportController: + @tool(name="build_report", description="Build a report", input_schema=ReportInput, task_support="optional") + async def build_report(self, input: ReportInput, context: ExecutionContext) -> dict: + for step in range(1, input.steps + 1): + if context.task: + context.task.update_progress(f"Step {step}/{input.steps}") + context.task.throw_if_cancelled() + await asyncio.sleep(1) + return {"steps": input.steps} +``` + +### Events and health checks + +```python +from typing import List +from nitrostack import EventEmitter, health_check, injectable, on_event + +@injectable() +class OrderService: + async def place(self, order_id: str) -> None: + await EventEmitter.get_instance().emit("order.placed", {"id": order_id}) + +@injectable() +class AuditLog: + def __init__(self): + self.entries: List[str] = [] + + @on_event("order.placed") + def record(self, payload: dict) -> None: + self.entries.append(payload["id"]) + + @health_check("audit") + def healthy(self) -> bool: + return True +``` + +Listeners and health checks are found on any provider or controller in a module. When any health check exists, the server exposes the `health://status` resource (`{"audit": "healthy"}`). Keep health checks synchronous. + --- ## Transport Options @@ -279,28 +447,93 @@ server = ServerConfig(name="my-server", transport_type="http", max_sessions=100, --- -## Developing & Testing +## CLI (`nitrostack-py`) + +The CLI is installed with the SDK (`nitrostack-py`, or `python -m nitrostack.cli.main`). Run `nitrostack-py --help` to list commands. + +### Project lifecycle -### Auto-Registering with Claude Desktop -To automatically configure your server script with Claude Desktop without any manual editing: ```bash -nitrostack-py register --name my-mcp-server --file app.py +nitrostack-py init my-server +nitrostack-py dev # hot-reload development server +nitrostack-py start # production server (no reload) +nitrostack-py register --name my-mcp-server --file main.py ``` -*(If your scripts folder is not in PATH, use: `python -m nitrostack.cli.main register --name my-mcp-server --file app.py`)* -This detects all standard and Windows Store installation directories, sets up virtualenv executables, and writes the JSON configuration. Once registered, simply restart Claude Desktop. +### Generate components -### Widgets (UI tools) +```bash +nitrostack-py generate tool add_numbers +nitrostack-py generate module payments +nitrostack-py generate guard MyGuard +nitrostack-py generate pipe Validation +nitrostack-py generate interceptor Transform +nitrostack-py generate filter HttpException +nitrostack-py generate service Email +``` -Bind a static HTML template to a tool with `@widget` and return domain JSON from the handler: +Generated files: + +| Command | Output | +|---|---| +| `generate tool ` | `{name}_tool.py` in the current directory | +| `generate module ` | `{name}_module.py` in the current directory | +| `generate guard ` | `guards/.py` | +| `generate pipe ` | `pipes/.py` | +| `generate interceptor ` | `interceptors/.py` | +| `generate filter ` | `filters/.py` | +| `generate service ` | `services/.py` | + +Attach generated pipeline classes with `@use_guards`, `@use_pipes`, `@use_interceptors`, or `@use_filters`. Register services in a module's `providers` list. + +### Pack a deployable wheel + +```bash +nitrostack-py pack --dry-run # list files; does not write an artifact +nitrostack-py pack # write dist/*.whl +``` + +`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. The real `.env` file and other secrets are never packed. Temporary build directories are deleted afterwards. + +### Upgrade, install, validate + +```bash +nitrostack-py upgrade # latest nitrostack on PyPI (writes nitrostack>=latest) +nitrostack-py upgrade --version 0.4.1 # pin exactly this version (writes nitrostack==0.4.1) +nitrostack-py upgrade --dry-run # print the change; do not edit files +nitrostack-py upgrade --allow-downgrade --version 0.1.0 # required to pin an older version + +nitrostack-py install # install project + development dependencies +nitrostack-py install --production # skip optional extras and requirements-dev.txt + +nitrostack-py validate # lint deps, @mcp_app imports, and @module() refs +``` + +`init` writes `pyproject.toml`, `.python-version`, and `uv.toml`, then runs `uv lock` when `uv` is on PATH. `install` prefers `uv sync` in that case. If `requirements.txt` pins nitrostack as a local path (`-e /path/to/nitrostack-python-sdk`), install uses `pip install -r requirements.txt` instead so unpublished SDK testing still works. The uv equivalent is: + +```toml +[tool.uv.sources] +nitrostack = { path = "/path/to/nitrostack-python-sdk", editable = true } +``` + +then `uv lock` / `uv sync`. Without `uv`, install falls back to `.venv` + `pip`. + +`upgrade` updates the `nitrostack` dependency spec in `pyproject.toml` in place (and `requirements.txt` when it already pins nitrostack). `--version X` writes `nitrostack==X`. Without `--version`, the latest PyPI release is written as `nitrostack>=latest`. A target older than the currently declared version is rejected unless `--allow-downgrade` is passed. `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes. + +--- + +## Widgets (UI tools) + +Bind an HTML template to a tool with `@widget` and return domain JSON from the handler: ```python from nitrostack import tool, widget, ExecutionContext -@tool(name="show_card", description="Product card", input_schema=CardInput) -@widget("card") -async def show_card(self, input: CardInput, context: ExecutionContext) -> dict: - return {"name": "Widget", "price": 9.99} +class ShopController: + @tool(name="show_card", description="Product card", input_schema=CardInput) + @widget("card") + async def show_card(self, input: CardInput, context: ExecutionContext) -> dict: + return {"name": "Widget", "price": 9.99} ``` Place HTML at `widgets/out/{route}.html` (e.g. `widgets/out/card.html`). The SDK registers @@ -325,6 +558,8 @@ from nitrostack import WidgetOptions, WidgetCsp, widget prefers_border=True, csp=WidgetCsp(connect_domains=["https://api.example.com"]), )) +async def show_chart(self, input, context): + ... ``` `nitrostack-py init` copies `widgets/out/{route}.html` for every `@widget`. Widget HTML @@ -334,7 +569,7 @@ MCP Inspector: use **HTTP + stateless**, then the **Apps** tab. `tools/call` als the data-filled HTML. Do not use `widgets/preview.html` as the live result — that file is a static helper. Live preview: `http://localhost:3000/widgets/preview`. -Turn **Authentication off** in Inspector. Pizzaz/starter have no OAuth. If Auth is on, +Turn **Authentication off** in Inspector for the starter and pizzaz templates (they have no OAuth). If Auth is on, Inspector POSTs `/register` and you will see `Cannot POST /register` / `Unexpected token '<'`. Connect Streamable HTTP to `http://localhost:3000/mcp` (no trailing slash). @@ -342,11 +577,6 @@ For **open pizza shops only**, call `show_pizza_list` with `{"openNow": true}` o `show_pizza_map` with `{"filter": "open_now"}`. Omitting those fields returns every shop, including closed ones (Pizzeria Delfina). -**NitroStudio:** folder-connect looks for a TypeScript project (`package.json` with -`@nitrostack/core` and `src/index.ts`). A Python server will not detect. Keep using -MCP Inspector over HTTP, or point Studio at a custom Streamable HTTP URL if the build -supports it. Do not enable OAuth against this server. - Example server: `examples/widgets_example.py` with templates in `examples/widgets/out/`. For MCP Inspector over HTTP, use stateless mode: @@ -355,24 +585,32 @@ cd examples MCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal python widgets_example.py ``` -### Running Tests -To run the automated test suite, execute: +--- + +## Using with Clients + +### NitroStudio + +Open the project folder in NitroStudio. It recognises a Python NitroStack project by `main.py` plus `nitrostack` in `requirements.txt` or `pyproject.toml`, starts it, and lets you call tools, preview widgets, chat with it, and run OAuth. Studio passes the port it picks through `PORT`, which wins over `.env` (0.4.1 and later), so several projects can run side by side. + +### Claude Desktop + ```bash -python tests/test_basic.py -python tests/test_tasks.py -python tests/test_initial_tool.py -python tests/test_transports.py -python tests/test_widgets.py -python tests/test_widget_metadata.py -python tests/test_pizzaz_widgets.py -python tests/test_template_widgets.py -python tests/test_cli.py -pytest tests/test_cli.py -v -python tests/test_tool_input_schema.py +nitrostack-py register --name my-mcp-server --file main.py ``` +*(If your scripts folder is not in PATH, use: `python -m nitrostack.cli.main register --name my-mcp-server --file main.py`)* + +This finds the Claude Desktop config (standard and Windows Store installs), points it at your virtualenv's Python, and writes the server entry. Restart Claude Desktop afterwards. + +--- + +## Testing + ### Testing Harness -Write in-process unit tests using the harness: + +Call tools, resources and prompts in-process with `NitroTestingModule`; no transport, no subprocess: + ```python import asyncio from nitrostack.testing import NitroTestingModule @@ -380,10 +618,18 @@ from app import AppModule async def test_add(): harness = await NitroTestingModule.create(AppModule) - result = await harness.call_tool("add", {"input": {"a": 5, "b": 10}}) - assert result == 15.0 - print("Test passed!") + assert await harness.call_tool("add", {"a": 5, "b": 10}) == 15.0 + assert await harness.read_resource("calc://info") == "Simple Add Calculator v1.0.0" if __name__ == "__main__": asyncio.run(test_add()) ``` + +`call_tool` takes the tool's arguments as an MCP client sends them and returns the decoded result (JSON is parsed; errors come back as their message). `get_prompt(name, args)` returns the prompt messages. + +### Running the SDK's tests + +```bash +pip install -e ".[dev]" +pytest tests +``` diff --git a/pyproject.toml b/pyproject.toml index 68ea96b..06867ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,12 @@ dev = [ "tox>=4.0.0", ] +[project.urls] +Homepage = "https://nitrostack.ai/" +Documentation = "https://docs.nitrostack.ai/python" +Repository = "https://github.com/nitrocloudofficial/nitrostack-python-sdk" +Issues = "https://github.com/nitrocloudofficial/nitrostack-python-sdk/issues" + [project.scripts] nitrostack-py = "nitrostack.cli.main:main" diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..0e004fd --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,148 @@ +"""README examples must stay true to the SDK: PyPI shows this README. + +Every ```python block must compile and import only names the SDK has. The +Quick Start and Building Blocks examples are also run through the testing +harness. +""" +from __future__ import annotations + +import asyncio +import importlib +import os +import re +import sys +import types + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, ROOT) + +from nitrostack import DIContainer, NitroTestingModule, module # noqa: E402 +from nitrostack.core.additional_decorators import HealthCheckRegistry # noqa: E402 +from nitrostack.events.event_emitter import EventEmitter # noqa: E402 + +README = open(os.path.join(ROOT, "README.md"), encoding="utf-8").read() +FENCE = re.compile(r"^```python\n(.*?)^```", re.S | re.M) + + +def _section(title: str) -> str: + match = re.search(rf"^## {re.escape(title)}\n(.*?)(?=^## |\Z)", README, re.S | re.M) + assert match, f"README has no '## {title}' section" + return match.group(1) + + +def _blocks(text: str): + return FENCE.findall(text) + + +@pytest.fixture(autouse=True) +def fresh_globals(): + DIContainer.reset() + EventEmitter.reset() + HealthCheckRegistry._bound_checks.clear() + yield + DIContainer.reset() + EventEmitter.reset() + HealthCheckRegistry._bound_checks.clear() + + +def test_every_python_block_compiles(): + blocks = _blocks(README) + assert len(blocks) >= 10 + for i, code in enumerate(blocks): + compile(code, f"README.md python block {i + 1}", "exec") + + +def test_every_nitrostack_import_exists(): + imports = re.compile(r"^from (nitrostack[\w.]*) import (\([^)]*\)|[^\n]*)", re.M) + checked = 0 + for code in _blocks(README): + for mod_name, names in imports.findall(code): + mod = importlib.import_module(mod_name) + for name in re.split(r"[,\s()]+", names): + if not name or name.startswith("#"): + continue + assert hasattr(mod, name), f"README imports {name!r} from {mod_name}, which doesn't have it" + checked += 1 + assert checked >= 30 + + +def test_quick_start_runs(): + code = _blocks(_section("Quick Start"))[0] + app = types.ModuleType("readme_quick_start_app") + exec(compile(code, "README quick start", "exec"), app.__dict__) + + async def run(): + harness = await NitroTestingModule.create(app.AppModule) + assert await harness.call_tool("add", {"a": 5, "b": 10}) == 15.0 + assert await harness.read_resource("calc://info") == "Simple Add Calculator v1.0.0" + + asyncio.run(run()) + + +def test_building_blocks_run(monkeypatch): + monkeypatch.setenv("WEATHER_API_KEY", "test") + monkeypatch.setenv("API_KEY", "secret") + monkeypatch.delenv("OAUTH_REQUIRED", raising=False) + + # The section's blocks build on each other; keep every controller and + # service each block defines (several reuse the name WeatherController). + namespace: dict = {"__name__": "readme_building_blocks"} + controllers, providers = [], [] + for i, code in enumerate(_blocks(_section("Building Blocks"))): + before = dict(namespace) + exec(compile(code, f"README building block {i + 1}", "exec"), namespace) + for name, value in namespace.items(): + if not isinstance(value, type) or before.get(name) is value: + continue + if name.endswith("Controller"): + controllers.append(value) + elif name in ("WeatherService", "OrderService", "AuditLog"): + providers.append(value) + + @module(name="app", imports=[namespace["ApiKeyModule"].for_root()], controllers=controllers, providers=providers) + class AppModule: + pass + + async def run(): + h = await NitroTestingModule.create(AppModule) + assert await h.call_tool("get_weather", {"city": "Paris"}) == {"city": "Paris", "temp": 21, "units": "metric"} + assert await h.call_tool("whats_new", {}) == "Try get_weather." + assert await h.read_resource("weather://units") == "metric" + messages = await h.get_prompt("forecast_brief", {"city": "Oslo"}) + assert "Oslo" in str(messages) + + assert await h.call_tool("get_forecast", {"city": "Rome"}) == {"city": "Rome", "forecast": "sunny"} + assert "Access denied" in str(await h.call_tool("get_balance", {})) + assert "Not authenticated" in str(await h.call_tool("close_account", {})) + + assert [await h.call_tool("ping", {}) for _ in range(10)] == ["pong"] * 10 + assert "Rate limit exceeded" in str(await h.call_tool("ping", {})) + + service = DIContainer.get_instance().resolve(namespace["WeatherService"]) + assert await service.lookup("Paris") is await service.lookup("Paris") + + assert await h.call_tool("build_report", {"steps": 1}) == {"steps": 1} + + await DIContainer.get_instance().resolve(namespace["OrderService"]).place("A1") + assert DIContainer.get_instance().resolve(namespace["AuditLog"]).entries == ["A1"] + assert await h.read_resource("health://status") == {"audit": "healthy"} + + asyncio.run(run()) + + +def test_config_service_example(tmp_path, monkeypatch): + code = _blocks(_section("Quick Start"))[1] + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("WEATHER_API_KEY", "k") + monkeypatch.delenv("WEATHER_UNITS", raising=False) + namespace: dict = {"__name__": "readme_config"} + exec(compile(code, "README config example", "exec"), namespace) + + async def run(): + await NitroTestingModule.create(namespace["AppModule"]) + service = DIContainer.get_instance().resolve(namespace["WeatherService"]) + assert (service.units, service.api_key) == ("metric", "k") + + asyncio.run(run()) From e987335451b4fbf7edf36a243c1ffc044a34a32b Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 06:18:47 +0530 Subject: [PATCH 09/16] docs: don't present upgrade as the way to refresh the vendored SDK Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66b02cd..daca9bf 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ nitrostack-py dev --port 4000 --widget 4001 nitrostack-py start --port 4000 --widget 4001 ``` -`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. Because the SDK is vendored, `nitrostack-py upgrade` (below) is how an existing project picks up a new SDK release. +`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. The project runs that vendored copy, whatever the version spec says. Once scaffolded, follow the next steps the CLI prints. From f29cff07aadb15d0ab47e6f4e5aa31e64bb6259f Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 06:55:28 +0530 Subject: [PATCH 10/16] Fix upgrade on vendored projects; correct init's NitroStudio note `nitrostack-py upgrade` failed on every project `init` creates: the dependency pattern also matched `nitrostack = { path = "vendor/nitrostack" }` in [tool.uv.sources] and produced an invalid pyproject.toml. Spec rewrites are now limited to [project].dependencies, and the pattern skips paths ending in /nitrostack. Fixing that alone isn't enough: a vendored project runs vendor/nitrostack whatever spec it declares, and uv accepts a spec the vendored copy doesn't satisfy. For vendored projects, upgrade now replaces vendor/nitrostack with the target release (copied from the running CLI when it is that release, otherwise the PyPI wheel, checked against its sha256). The new tree is built beside the old one and swapped in, so a failed download leaves the project as it was. init's next steps said NitroStudio can't open Python projects; it can. The pizzaz open-shops hint is printed only for the pizzaz template. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- nitrostack/cli/main.py | 6 +- nitrostack/cli/upgrade.py | 155 ++++++++++++++++++- nitrostack/cli/vendor.py | 130 ++++++++++++++-- tests/test_cli_upgrade_vendored.py | 234 +++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 25 deletions(-) create mode 100644 tests/test_cli_upgrade_vendored.py diff --git a/README.md b/README.md index daca9bf..ff05e8d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ nitrostack-py dev --port 4000 --widget 4001 nitrostack-py start --port 4000 --widget 4001 ``` -`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. The project runs that vendored copy, whatever the version spec says. +`init` and `pack` vendor this SDK into `vendor/nitrostack` and pin it with `[tool.uv.sources]`. The dependency name stays `nitrostack` in `pyproject.toml` and `requirements.txt`, so `nitrostack-py install` and NitroCloud still run `uv sync`. Do not put a `file:` URL in `[project].dependencies`; that breaks the cloud wheel metadata. The project runs that vendored copy, whatever the version spec says; `nitrostack-py upgrade` replaces it with another release. Once scaffolded, follow the next steps the CLI prints. @@ -498,8 +498,8 @@ nitrostack-py pack # write dist/*.whl ### Upgrade, install, validate ```bash -nitrostack-py upgrade # latest nitrostack on PyPI (writes nitrostack>=latest) -nitrostack-py upgrade --version 0.4.1 # pin exactly this version (writes nitrostack==0.4.1) +nitrostack-py upgrade # move the project to the latest nitrostack on PyPI +nitrostack-py upgrade --version 0.4.1 # move it to exactly this version nitrostack-py upgrade --dry-run # print the change; do not edit files nitrostack-py upgrade --allow-downgrade --version 0.1.0 # required to pin an older version @@ -518,7 +518,7 @@ nitrostack = { path = "/path/to/nitrostack-python-sdk", editable = true } then `uv lock` / `uv sync`. Without `uv`, install falls back to `.venv` + `pip`. -`upgrade` updates the `nitrostack` dependency spec in `pyproject.toml` in place (and `requirements.txt` when it already pins nitrostack). `--version X` writes `nitrostack==X`. Without `--version`, the latest PyPI release is written as `nitrostack>=latest`. A target older than the currently declared version is rejected unless `--allow-downgrade` is passed. `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes. +`upgrade` in a project with `vendor/nitrostack` (every project `init` creates) replaces the vendored copy with the target release: the wheel is downloaded from PyPI and checked against its published sha256, or copied from the running CLI when it is that release. The dependency stays the plain name `nitrostack`. In other projects, `upgrade` updates the `nitrostack` spec in `pyproject.toml`'s `dependencies` (and `requirements.txt` when it already pins nitrostack): `--version X` writes `nitrostack==X`, and without `--version` the latest release is written as `nitrostack>=latest`. Either way, run `nitrostack-py install` afterwards, and a target older than the current version is rejected unless `--allow-downgrade` is passed. `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes. --- diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1ab03b3..6e5553c 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1409,9 +1409,9 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa print(" 5. Inspector (HTTP): \033[34mMCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal python main.py\033[0m") print(" Connect Streamable HTTP to \033[34mhttp://localhost:3000/mcp\033[0m with \033[34mAuthentication = Off\033[0m") print(" Apps tab renders the widget from live \033[34mstructuredContent\033[0m (not widgets/preview.html)") - print(" For open shops only set \033[34mopenNow=true\033[0m (list) or \033[34mfilter=open_now\033[0m (map)") - print(" 6. NitroStudio cannot folder-connect a Python project (it looks for @nitrostack/core).") - print(" Use Inspector HTTP as above, or Studio's custom MCP URL if it offers Streamable HTTP.") + if template == "python-pizzaz": + print(" For open shops only set \033[34mopenNow=true\033[0m (list) or \033[34mfilter=open_now\033[0m (map)") + print(" 6. NitroStudio: open this folder as a project; Studio starts and connects it.") print("\nHappy coding! 🚀\n") def run_dev(port=None, widget=None): diff --git a/nitrostack/cli/upgrade.py b/nitrostack/cli/upgrade.py index 12a312a..64e600a 100644 --- a/nitrostack/cli/upgrade.py +++ b/nitrostack/cli/upgrade.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import os import re @@ -19,13 +20,20 @@ write_text_atomic, ) from nitrostack.cli.skills import upgrade_agent_skills +from nitrostack.cli.vendor import ( + VENDOR_REL, + _distribution_version, + replace_vendored_nitrostack, + vendored_version, +) PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" PYPI_VERSION_JSON = "https://pypi.org/pypi/nitrostack/{version}/json" -# Name must not be a prefix of nitrostack-studio / nitrostack_extras / nitrostack.contrib. +# Name must not be a prefix of nitrostack-studio / nitrostack_extras / nitrostack.contrib, +# nor the tail of a path such as ./vendor/nitrostack. _DEP_RE = re.compile( - r'(["\']?)(nitrostack)(?![\w.-])((?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*[^"\'\s,#]+)?)(\1)', + r'(?=|<=|>|<)\s*[^"\'\s,#]+)?)(\1)', re.IGNORECASE, ) @@ -75,6 +83,63 @@ def verify_nitrostack_version(version: str, timeout: float = 15.0) -> None: raise UpgradeError(f"Could not reach PyPI to verify nitrostack=={version}: {exc}") from exc +def fetch_nitrostack_wheel(version: str, timeout: float = 60.0) -> bytes: + """Download the nitrostack wheel for ``version`` from PyPI and check its sha256.""" + req = urllib.request.Request( + PYPI_VERSION_JSON.format(version=version), + headers={"Accept": "application/json", "User-Agent": "nitrostack-py"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + wheels = [ + f for f in payload.get("urls") or [] + if f.get("packagetype") == "bdist_wheel" + and str(f.get("filename", "")).endswith("-none-any.whl") + ] + if not wheels: + raise UpgradeError(f"PyPI has no wheel for nitrostack=={version}.") + wheel = wheels[0] + expected = (wheel.get("digests") or {}).get("sha256") + if not expected: + raise UpgradeError( + f"PyPI lists no sha256 for {wheel.get('filename')}; not installing it." + ) + download = urllib.request.Request(wheel["url"], headers={"User-Agent": "nitrostack-py"}) + with urllib.request.urlopen(download, timeout=timeout) as resp: + data = resp.read() + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise UpgradeError(f"nitrostack=={version} was not found on PyPI.") from exc + raise UpgradeError(f"Downloading nitrostack=={version} failed: {exc}") from exc + except urllib.error.URLError as exc: + raise UpgradeError( + f"Could not reach PyPI to download nitrostack=={version}: {exc}" + ) from exc + except json.JSONDecodeError as exc: + raise UpgradeError(f"PyPI returned invalid JSON: {exc}") from exc + if hashlib.sha256(data).hexdigest() != expected: + raise UpgradeError(f"{wheel.get('filename')} failed its sha256 check; not installing it.") + return data + + +def _pyproject_dependencies(text: str) -> Tuple[int, int]: + """Span of `[project].dependencies`, or (0, 0). Specs elsewhere (e.g. a + `[tool.uv.sources]` entry named nitrostack) must not be rewritten.""" + return find_toml_array_inner(text, "dependencies") or (0, 0) + + +def _find_pyproject_spec(text: str) -> Optional[str]: + start, end = _pyproject_dependencies(text) + return find_current_spec(text[start:end]) + + +def _replace_pyproject_spec(text: str, spec: str) -> Tuple[str, int]: + start, end = _pyproject_dependencies(text) + inner, count = replace_nitrostack_spec(text[start:end], spec) + return text[:start] + inner + text[end:], count + + def find_current_spec(text: str) -> Optional[str]: match = _DEP_RE.search(text) if not match: @@ -145,6 +210,68 @@ def _commit_file_changes(changes: list) -> list: raise +def _upgrade_vendored( + root: str, + current: str, + *, + version: Optional[str], + dry_run: bool, + verify: bool, + allow_downgrade: bool, +) -> dict: + """Replace `vendor/nitrostack` with the target release. + + `init` vendors the SDK and points uv at it with `[tool.uv.sources]`, so the + project runs that copy whatever version spec it declares. The dependency + stays the plain name `nitrostack` (see `vendor.py`). + """ + target = version or fetch_latest_nitrostack_version() + if version and verify: + verify_nitrostack_version(target) + try: + target_ver = Version(target) + except InvalidVersion as exc: + raise UpgradeError(f"Invalid target version '{target}': {exc}") from exc + try: + current_ver: Optional[Version] = Version(current) + except InvalidVersion: + current_ver = None + if not allow_downgrade and current_ver is not None and target_ver < current_ver: + raise UpgradeError( + f"Target {target} is older than the vendored nitrostack {current}. " + "Re-run with --allow-downgrade if you intend to downgrade." + ) + + vendor_dir = VENDOR_REL.replace(os.sep, "/") + change = {"file": vendor_dir, "from": current, "to": target} + up_to_date = current_ver == target_ver + print("NITROSTACK — Upgrade" + (" (dry run)" if dry_run else "")) + print(f"Target version: {target}") + print(f" {vendor_dir}: {current} → {target}" + (" (already installed)" if up_to_date else "")) + + written: list = [] + if dry_run: + print("\nDry run — no files modified.") + elif not up_to_date: + # The running CLI is the target release: copy it rather than download it. + wheel = None if _distribution_version() == target else fetch_nitrostack_wheel(target) + replace_vendored_nitrostack(root, wheel) + written = [vendor_dir] + print(f"Updated {vendor_dir}") + print(f"\nUpgrade complete. The project now runs nitrostack {target}.") + print("Run `nitrostack-py install` to install it.") + + skills = upgrade_agent_skills(root, dry_run=dry_run) + return { + "version": target, + "spec": "nitrostack", + "changes": [] if up_to_date else [change], + "dry_run": dry_run, + "written": written, + "skills": skills, + } + + def upgrade_project( root: Optional[str] = None, *, @@ -153,7 +280,12 @@ def upgrade_project( verify: bool = True, allow_downgrade: bool = False, ) -> dict: - """Update the nitrostack dependency spec in pyproject.toml (and requirements.txt if present).""" + """Move the project to another nitrostack release. + + Vendored projects (`vendor/nitrostack`, as `init` creates) get the vendored copy + replaced. Others get the dependency spec in pyproject.toml (and requirements.txt + if present) rewritten. + """ root = os.path.abspath(root or os.getcwd()) pyproject = os.path.join(root, "pyproject.toml") requirements = os.path.join(root, "requirements.txt") @@ -164,6 +296,17 @@ def upgrade_project( "Run this command from a NitroStack project, or create pyproject.toml first." ) + vendored = vendored_version(root) + if vendored is not None: + return _upgrade_vendored( + root, + vendored, + version=version, + dry_run=dry_run, + verify=verify, + allow_downgrade=allow_downgrade, + ) + target = version or fetch_latest_nitrostack_version() if version and verify: verify_nitrostack_version(target) @@ -175,7 +318,7 @@ def upgrade_project( current_specs = [] if original_pyproject is not None: - current_specs.append(find_current_spec(original_pyproject)) + current_specs.append(_find_pyproject_spec(original_pyproject)) if original_reqs is not None: current_specs.append(find_current_spec(original_reqs)) @@ -194,8 +337,8 @@ def upgrade_project( ) if original_pyproject is not None: - current = find_current_spec(original_pyproject) - updated, n = replace_nitrostack_spec(original_pyproject, new_spec) + current = _find_pyproject_spec(original_pyproject) + updated, n = _replace_pyproject_spec(original_pyproject, new_spec) if n == 0: updated = _add_to_pyproject_dependencies(original_pyproject, new_spec) current = current or "(missing)" diff --git a/nitrostack/cli/vendor.py b/nitrostack/cli/vendor.py index af35dd7..08abbea 100644 --- a/nitrostack/cli/vendor.py +++ b/nitrostack/cli/vendor.py @@ -8,13 +8,18 @@ """ from __future__ import annotations +import email.parser +import io +import json import os import re import shutil +import tempfile +import zipfile from pathlib import Path -from typing import Optional +from typing import List, Optional -from nitrostack.cli._shared import write_text_atomic +from nitrostack.cli._shared import loads_toml, write_text_atomic VENDOR_REL = os.path.join("vendor", "nitrostack") _PLAIN_REQUIREMENT = "nitrostack" @@ -87,8 +92,25 @@ def _distribution_version() -> str: ) -def _write_minimal_pyproject(dest: Path) -> None: - version = _distribution_version() +_DEFAULT_DEPENDENCIES = ( + "mcp>=2,<3", + "pydantic>=2.0.0", + "starlette>=0.30.0", + "uvicorn>=0.20.0", + "watchfiles>=0.18.0", + "anyio>=4.0.0", + "packaging>=23.0.0", + "tomli>=2.0.0; python_version < '3.11'", +) + + +def _write_minimal_pyproject( + dest: Path, + version: Optional[str] = None, + dependencies: Optional[List[str]] = None, +) -> None: + version = version or _distribution_version() + deps = "".join(f" {json.dumps(dep)},\n" for dep in (dependencies or _DEFAULT_DEPENDENCIES)) dest.mkdir(parents=True, exist_ok=True) (dest / "pyproject.toml").write_text( "[build-system]\n" @@ -100,14 +122,7 @@ def _write_minimal_pyproject(dest: Path) -> None: 'description = "NitroStack Python SDK (vendored)"\n' 'requires-python = ">=3.10"\n' "dependencies = [\n" - ' "mcp>=2,<3",\n' - ' "pydantic>=2.0.0",\n' - ' "starlette>=0.30.0",\n' - ' "uvicorn>=0.20.0",\n' - ' "watchfiles>=0.18.0",\n' - ' "anyio>=4.0.0",\n' - ' "packaging>=23.0.0",\n' - ' "tomli>=2.0.0; python_version < \'3.11\'",\n' + f"{deps}" "]\n\n" "[project.scripts]\n" 'nitrostack-py = "nitrostack.cli.main:main"\n\n' @@ -142,6 +157,10 @@ def copy_running_nitrostack(dest: Path) -> None: else: _write_minimal_pyproject(dest) + _check_vendored_tree(dest) + + +def _check_vendored_tree(dest: Path) -> None: assets = dest / "nitrostack" / "transports" / "assets" / "landing.html" if not assets.is_file(): raise RuntimeError( @@ -150,6 +169,93 @@ def copy_running_nitrostack(dest: Path) -> None: ) +def extract_nitrostack_wheel(wheel: bytes, dest: Path) -> str: + """Unpack a nitrostack wheel into ``dest`` as a vendored tree; return its version. + + Skips the same directories as `copy_running_nitrostack` (templates, caches). + """ + with zipfile.ZipFile(io.BytesIO(wheel)) as archive: + metadata_name = next( + ( + n for n in archive.namelist() + if re.fullmatch(r"nitrostack-[^/]+\.dist-info/METADATA", n) + ), + None, + ) + if metadata_name is None: + raise RuntimeError("The downloaded file is not a nitrostack wheel (no METADATA).") + metadata = email.parser.Parser().parsestr(archive.read(metadata_name).decode("utf-8")) + version = metadata.get("Version") + if not version: + raise RuntimeError("The nitrostack wheel has no version in its METADATA.") + requires = [ + req for req in metadata.get_all("Requires-Dist") or [] + if not re.search(r"\bextra\s*==", req) + ] + + root = dest.resolve() + for name in archive.namelist(): + parts = name.split("/") + if parts[0] != "nitrostack" or name.endswith("/"): + continue + if any(part in _SKIP_DIR_NAMES or part.endswith(".egg-info") for part in parts[:-1]): + continue + target = (dest / name).resolve() + if root not in target.parents: + raise RuntimeError(f"Refusing to extract {name!r} outside {dest}.") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(name)) + + _write_minimal_pyproject(dest, version=version, dependencies=requires) + _check_vendored_tree(dest) + return version + + +def vendored_version(project: str) -> Optional[str]: + """Version of the SDK in ``project/vendor/nitrostack``, or None if not vendored.""" + pyproject = Path(project) / VENDOR_REL / "pyproject.toml" + if not pyproject.is_file(): + return None + try: + data = loads_toml(pyproject.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + except Exception: + return None + return str(version) if version else None + + +def replace_vendored_nitrostack(project: str, wheel: Optional[bytes] = None) -> str: + """Swap ``vendor/nitrostack`` for the running SDK, or for ``wheel`` when given. + + The new tree is built next to the old one and swapped in afterwards, so a + failed download or extraction leaves the project's current SDK in place. + Returns the vendored version. + """ + dest = Path(os.path.abspath(project)) / VENDOR_REL + dest.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".nitrostack-new-", dir=dest.parent)) + backup = dest.parent / f".nitrostack-old-{os.getpid()}" + try: + if wheel is None: + copy_running_nitrostack(staging) + version = _distribution_version() + else: + version = extract_nitrostack_wheel(wheel, staging) + if dest.exists(): + os.replace(dest, backup) + try: + os.replace(staging, dest) + except OSError: + if backup.exists(): + os.replace(backup, dest) + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + shutil.rmtree(backup, ignore_errors=True) + pin_project_to_vendor(Path(os.path.abspath(project))) + return version + + def _pin_text(text: str) -> str: """Keep `[project].dependencies` as a plain name (valid wheel Requires-Dist).""" return _QUOTED_NITROSTACK.sub(lambda match: f"{match.group(1)}nitrostack{match.group(1)}", text) diff --git a/tests/test_cli_upgrade_vendored.py b/tests/test_cli_upgrade_vendored.py new file mode 100644 index 0000000..8cc09c8 --- /dev/null +++ b/tests/test_cli_upgrade_vendored.py @@ -0,0 +1,234 @@ +"""`nitrostack-py upgrade` on projects shaped like `init` creates them. + +`init` vendors the SDK into vendor/nitrostack and points uv at it with a +`[tool.uv.sources]` entry, so upgrade must replace that copy and must not +touch the source entry. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nitrostack.cli import vendor +from nitrostack.cli._shared import loads_toml +from nitrostack.cli.upgrade import ( + UpgradeError, + fetch_nitrostack_wheel, + replace_nitrostack_spec, + upgrade_project, +) +from nitrostack.cli.vendor import ( + ensure_vendored_nitrostack, + extract_nitrostack_wheel, + vendored_version, +) + +INIT_PYPROJECT = ( + "[project]\n" + 'name = "demo"\n' + 'version = "0.1.0"\n' + 'requires-python = ">=3.10"\n' + "dependencies = [\n" + ' "nitrostack",\n' + "]\n\n" + "[tool.uv.sources]\n" + 'nitrostack = { path = "vendor/nitrostack" }\n' +) + + +def _wheel(version: str, extra_files: dict | None = None) -> bytes: + files = { + "nitrostack/__init__.py": f"VERSION = {version!r}\n", + "nitrostack/transports/assets/landing.html": "", + "nitrostack/templates/starter/main.py": "print('template')\n", + f"nitrostack-{version}.dist-info/METADATA": ( + "Metadata-Version: 2.1\n" + "Name: nitrostack\n" + f"Version: {version}\n" + "Requires-Dist: mcp<3,>=2\n" + "Requires-Dist: pydantic>=2.0.0\n" + 'Requires-Dist: tomli>=2.0.0; python_version < "3.11"\n' + 'Requires-Dist: pytest>=7.4.0; extra == "dev"\n' + ), + } + files.update(extra_files or {}) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +@pytest.fixture +def no_skills(): + with patch( + "nitrostack.cli.upgrade.upgrade_agent_skills", return_value={"skipped": True} + ) as mock: + yield mock + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (tmp_path / "pyproject.toml").write_text(INIT_PYPROJECT, encoding="utf-8") + (tmp_path / "requirements.txt").write_text("nitrostack\n", encoding="utf-8") + assert ensure_vendored_nitrostack(str(tmp_path), quiet=True) + return tmp_path + + +def test_spec_rewrite_skips_the_uv_source_entry(tmp_path: Path, no_skills): + # Not vendored, but the pyproject still names nitrostack in [tool.uv.sources]. + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(INIT_PYPROJECT, encoding="utf-8") + with patch("nitrostack.cli.upgrade.verify_nitrostack_version"): + upgrade_project(str(tmp_path), version="1.2.3", verify=False, allow_downgrade=True) + parsed = loads_toml(pyproject.read_text(encoding="utf-8")) + assert parsed["project"]["dependencies"] == ["nitrostack==1.2.3"] + assert parsed["tool"]["uv"]["sources"]["nitrostack"] == {"path": "vendor/nitrostack"} + + +def test_spec_rewrite_skips_vendor_paths(): + text = "-e ./vendor/nitrostack\nnitrostack>=0.3.0\n" + updated, count = replace_nitrostack_spec(text, "nitrostack>=9.9.9") + assert count == 1 + assert updated == "-e ./vendor/nitrostack\nnitrostack>=9.9.9\n" + + +def test_upgrade_replaces_the_vendored_sdk_from_the_wheel(project: Path, no_skills): + pyproject_before = (project / "pyproject.toml").read_text(encoding="utf-8") + with ( + patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"), + patch( + "nitrostack.cli.upgrade.fetch_nitrostack_wheel", return_value=_wheel("9.9.9") + ) as fetch, + ): + result = upgrade_project(str(project)) + + fetch.assert_called_once_with("9.9.9") + assert result["written"] == ["vendor/nitrostack"] + assert result["changes"][0]["to"] == "9.9.9" + assert vendored_version(str(project)) == "9.9.9" + sdk = project / "vendor" / "nitrostack" + assert (sdk / "nitrostack" / "__init__.py").read_text(encoding="utf-8") == "VERSION = '9.9.9'\n" + assert not (sdk / "nitrostack" / "templates").exists() + deps = loads_toml((sdk / "pyproject.toml").read_text(encoding="utf-8"))["project"][ + "dependencies" + ] + assert deps == ["mcp<3,>=2", "pydantic>=2.0.0", 'tomli>=2.0.0; python_version < "3.11"'] + # The project keeps the plain name; the vendored copy decides the version. + assert (project / "pyproject.toml").read_text(encoding="utf-8") == pyproject_before + assert (project / "requirements.txt").read_text(encoding="utf-8") == "nitrostack\n" + assert not [p for p in (project / "vendor").iterdir() if p.name.startswith(".nitrostack-")] + + +def test_upgrade_to_the_running_version_copies_it(project: Path, no_skills): + running = vendor._distribution_version() + (project / "vendor" / "nitrostack" / "pyproject.toml").write_text( + '[project]\nname = "nitrostack"\nversion = "0.0.1"\n', encoding="utf-8" + ) + with ( + patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value=running), + patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel") as fetch, + ): + upgrade_project(str(project)) + fetch.assert_not_called() + assert vendored_version(str(project)) == running + + +def test_upgrade_vendored_dry_run_same_version_and_downgrade(project: Path, no_skills): + current = vendored_version(str(project)) + snapshot = sorted(p.relative_to(project) for p in project.rglob("*")) + + with ( + patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"), + patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel") as fetch, + ): + dry = upgrade_project(str(project), dry_run=True) + fetch.assert_not_called() + assert dry["changes"] == [{"file": "vendor/nitrostack", "from": current, "to": "9.9.9"}] + assert dry["written"] == [] + + with ( + patch("nitrostack.cli.upgrade.verify_nitrostack_version"), + patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel") as fetch, + ): + same = upgrade_project(str(project), version=current) + with pytest.raises(UpgradeError, match="allow-downgrade"): + upgrade_project(str(project), version="0.0.1") + fetch.assert_not_called() + assert same["changes"] == [] and same["written"] == [] + assert sorted(p.relative_to(project) for p in project.rglob("*")) == snapshot + + +def test_failed_download_or_bad_wheel_keeps_the_current_sdk(project: Path, no_skills): + current = vendored_version(str(project)) + with patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"): + with patch( + "nitrostack.cli.upgrade.fetch_nitrostack_wheel", side_effect=UpgradeError("offline") + ): + with pytest.raises(UpgradeError, match="offline"): + upgrade_project(str(project)) + with patch("nitrostack.cli.upgrade.fetch_nitrostack_wheel", return_value=b"not a zip"): + with pytest.raises(zipfile.BadZipFile): + upgrade_project(str(project)) + assert vendored_version(str(project)) == current + assert ( + project / "vendor" / "nitrostack" / "nitrostack" / "transports" / "assets" / "landing.html" + ).is_file() + assert not [p for p in (project / "vendor").iterdir() if p.name.startswith(".nitrostack-")] + + +def test_extract_refuses_paths_outside_the_vendor_dir(tmp_path: Path): + evil = _wheel("9.9.9", {"nitrostack/../../escaped.py": "boom"}) + with pytest.raises(RuntimeError, match="outside"): + extract_nitrostack_wheel(evil, tmp_path / "sdk") + assert not (tmp_path / "escaped.py").exists() + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _pypi(wheel: bytes, digest: str): + payload = { + "urls": [ + { + "packagetype": "sdist", + "filename": "nitrostack-9.9.9.tar.gz", + "url": "https://x/sdist", + "digests": {}, + }, + { + "packagetype": "bdist_wheel", + "filename": "nitrostack-9.9.9-py3-none-any.whl", + "url": "https://x/wheel", + "digests": {"sha256": digest}, + }, + ] + } + + def urlopen(request, timeout=None): + url = request.full_url + return _Response(json.dumps(payload).encode() if url.endswith("/json") else wheel) + + return urlopen + + +def test_fetch_wheel_checks_sha256(): + wheel = _wheel("9.9.9") + good = hashlib.sha256(wheel).hexdigest() + with patch("urllib.request.urlopen", side_effect=_pypi(wheel, good)): + assert fetch_nitrostack_wheel("9.9.9") == wheel + with patch("urllib.request.urlopen", side_effect=_pypi(wheel, "0" * 64)): + with pytest.raises(UpgradeError, match="sha256"): + fetch_nitrostack_wheel("9.9.9") From 8b014eb89b96bed8e43539f2cd89959dcce26308 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:45:33 +0000 Subject: [PATCH 11/16] chore(release): 0.4.2 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06867ee..59850ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitrostack" -version = "0.4.1" +version = "0.4.2" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index bab7717..b4eb511 100644 --- a/uv.lock +++ b/uv.lock @@ -568,7 +568,7 @@ wheels = [ [[package]] name = "nitrostack" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "anyio" }, From b96606b5d84168ff073aba5969664d63e9c0bbaf Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 12:05:02 +0530 Subject: [PATCH 12/16] validate: skip the vendored SDK `init` vendors the SDK into vendor/nitrostack. `validate` walked it as project code, and nitrostack/testing applies @mcp_app to a class defined inside a function, so every vendored project that imported nitrostack.testing failed with "uses @mcp_app but no decorated application class was found after import". Compose's typecheck runs `validate`, so a NitroStudio build could not get past it. Co-Authored-By: Claude Opus 5.5 --- nitrostack/cli/validators.py | 5 +++++ tests/test_cli.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/nitrostack/cli/validators.py b/nitrostack/cli/validators.py index 990b154..3015aa4 100644 --- a/nitrostack/cli/validators.py +++ b/nitrostack/cli/validators.py @@ -21,6 +21,7 @@ read_text, split_requirement, ) +from nitrostack.cli.vendor import VENDOR_REL @dataclass @@ -39,10 +40,14 @@ def format(self) -> str: def _iter_python_files(root: str) -> Iterable[str]: + # The vendored SDK is not project code; its testing helpers apply @mcp_app + # inside functions, which reads as an app with no decorated class. + vendored_sdk = os.path.join(os.path.abspath(root), VENDOR_REL) for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [ d for d in dirnames if d not in EXCLUDE_DIR_NAMES and not d.endswith(".egg-info") + and os.path.join(os.path.abspath(dirpath), d) != vendored_sdk ] for filename in filenames: if filename.endswith(".py"): diff --git a/tests/test_cli.py b/tests/test_cli.py index 2aaf2ed..95046d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1154,6 +1154,33 @@ def test_validate_catches_broken_imports_and_module_refs(tmp_path: Path): assert "→" in messages or "Fix the import" in messages +def test_validate_skips_the_vendored_sdk(tmp_path: Path): + """`init` vendors the SDK; its testing helpers apply @mcp_app inside functions.""" + (tmp_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "vendored"\n' + 'version = "0.1.0"\n' + "dependencies = [\n" + ' "nitrostack",\n' + "]\n", + encoding="utf-8", + ) + sdk = tmp_path / "vendor" / "nitrostack" / "nitrostack" / "testing" + sdk.mkdir(parents=True) + (sdk / "__init__.py").write_text( + "from nitrostack import mcp_app, ServerConfig\n\n" + "def create(module):\n" + " @mcp_app(module=module, server=ServerConfig(name='test'))\n" + " class TestApp:\n" + " pass\n" + " return TestApp\n", + encoding="utf-8", + ) + + issues = validate_project(str(tmp_path)) + assert not [issue for issue in issues if "vendor" in issue.path], [i.format() for i in issues] + + def test_validate_accepts_module_only_bootstrap(tmp_path: Path): """Init templates use @module + McpApplicationFactory.create — no @mcp_app.""" (tmp_path / "pyproject.toml").write_text( From 76f5bde56ebee887cffb752acc21d00d4f1c5770 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 12:59:32 +0530 Subject: [PATCH 13/16] pack: add --include-env Align Python pack with the TypeScript CLI: optional inclusion of .env files in the wheel, with a warning that lists paths without printing values. --- README.md | 3 ++- nitrostack/cli/main.py | 9 +++++-- nitrostack/cli/pack.py | 60 +++++++++++++++++++++++++++++++++--------- tests/test_cli.py | 32 ++++++++++++++++++++++ 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ff05e8d..0f55682 100644 --- a/README.md +++ b/README.md @@ -491,9 +491,10 @@ Attach generated pipeline classes with `@use_guards`, `@use_pipes`, `@use_interc ```bash nitrostack-py pack --dry-run # list files; does not write an artifact nitrostack-py pack # write dist/*.whl +nitrostack-py pack --include-env # also pack .env files (use with caution!) ``` -`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. The real `.env` file and other secrets are never packed. Temporary build directories are deleted afterwards. +`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. `.env`, `.env.local`, and `.env.production` are excluded unless you pass `--include-env`. Temporary build directories are deleted afterwards. ### Upgrade, install, validate diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 6e5553c..e570c03 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1795,13 +1795,18 @@ def main(): # pack command pack_parser = subparsers.add_parser( "pack", - help="Build a deployable wheel of the current project (never includes .env/secrets)", + help="Build a deployable wheel of the current project (excludes .env by default)", ) pack_parser.add_argument( "--dry-run", action="store_true", help="Show files that would be packed without creating the artifact", ) + pack_parser.add_argument( + "--include-env", + action="store_true", + help="Include .env files in the wheel (use with caution!)", + ) # upgrade command upgrade_parser = subparsers.add_parser( @@ -1881,7 +1886,7 @@ def _run_command(fn, *fn_args, **fn_kwargs): else: generate_component(args.generator, args.name) elif args.command == "pack": - _run_command(pack_project, dry_run=args.dry_run) + _run_command(pack_project, dry_run=args.dry_run, include_env=args.include_env) elif args.command == "upgrade": _run_command( upgrade_project, diff --git a/nitrostack/cli/pack.py b/nitrostack/cli/pack.py index b2a805c..221a6bc 100644 --- a/nitrostack/cli/pack.py +++ b/nitrostack/cli/pack.py @@ -1,8 +1,9 @@ """Project packing for `nitrostack-py pack`. Builds a deployable wheel of the current project using the setuptools backend -already declared in NitroStack's packaging config. Secrets (``.env``) are never -included. Temporary build artifacts are cleaned up after packing. +already declared in NitroStack's packaging config. Secrets (``.env``) are excluded +by default; pass ``include_env`` to pack them. Temporary build artifacts are +cleaned up after packing. """ from __future__ import annotations @@ -110,10 +111,17 @@ def _matches_gitignore(rel_posix: str, patterns: Sequence[Tuple[str, bool]]) -> return ignored -def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence[Tuple[str, bool]]) -> bool: +def _should_exclude( + rel_posix: str, + name: str, + is_dir: bool, + gitignore: Sequence[Tuple[str, bool]], + *, + include_env: bool = False, +) -> bool: if name == ".env.example": return False - if _is_secret_env(name): + if _is_secret_env(name) and not include_env: return True if name in _EXCLUDE_DIR_NAMES and is_dir: return True @@ -128,7 +136,7 @@ def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence return False -def collect_pack_files(root: str) -> List[str]: +def collect_pack_files(root: str, *, include_env: bool = False) -> List[str]: """Return posix-relative paths that would be included in a pack.""" gitignore = _load_gitignore_patterns(root) included: List[str] = [] @@ -141,14 +149,14 @@ def collect_pack_files(root: str) -> List[str]: kept_dirs = [] for dirname in dirnames: child_rel = f"{rel_dir_posix}/{dirname}" if rel_dir_posix else dirname - if _should_exclude(child_rel, dirname, True, gitignore): + if _should_exclude(child_rel, dirname, True, gitignore, include_env=include_env): continue kept_dirs.append(dirname) dirnames[:] = kept_dirs for filename in filenames: child_rel = f"{rel_dir_posix}/{filename}" if rel_dir_posix else filename - if _should_exclude(child_rel, filename, False, gitignore): + if _should_exclude(child_rel, filename, False, gitignore, include_env=include_env): continue included.append(child_rel) @@ -348,14 +356,14 @@ def _is_valid_wheel(path: str) -> bool: return "Wheel-Version:" in body -def _collect_staging_bytes(src_root: str) -> dict: +def _collect_staging_bytes(src_root: str, *, include_env: bool = False) -> dict: contents = {} for dirpath, dirnames, filenames in os.walk(src_root): dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIR_NAMES and not d.endswith(".egg-info")] for filename in filenames: if any(filename.endswith(suffix) for suffix in _EXCLUDE_SUFFIXES): continue - if _is_secret_env(filename): + if _is_secret_env(filename) and not include_env: continue full = os.path.join(dirpath, filename) rel = os.path.relpath(full, src_root).replace("\\", "/") @@ -370,6 +378,8 @@ def _write_purelib_wheel( name: str, version: str, deps: Sequence[str], + *, + include_env: bool = False, ) -> str: """Write a PEP 427 py3-none-any wheel from the staged project files.""" os.makedirs(wheel_dir, exist_ok=True) @@ -379,7 +389,7 @@ def _write_purelib_wheel( wheel_path = os.path.join(wheel_dir, wheel_filename) dist_info = f"{dist_name}-{version}.dist-info" - contents = _collect_staging_bytes(src_root) + contents = _collect_staging_bytes(src_root, include_env=include_env) metadata_lines = [ "Metadata-Version: 2.1", f"Name: {display_name}", @@ -441,18 +451,34 @@ def _build_wheel( name: str, version: str, deps: Sequence[str], + *, + include_env: bool = False, ) -> str: os.makedirs(wheel_dir, exist_ok=True) built = _build_wheel_with_setuptools(src_root, wheel_dir) if built: return built - return _write_purelib_wheel(src_root, wheel_dir, name, version, deps) + return _write_purelib_wheel( + src_root, wheel_dir, name, version, deps, include_env=include_env + ) + + +def _secret_env_paths_in_pack(files: Sequence[str]) -> List[str]: + return sorted(rel for rel in files if _is_secret_env(rel.rsplit("/", 1)[-1])) + + +def _warn_including_env_files(env_files: Sequence[str]) -> None: + if not env_files: + return + listed = ", ".join(env_files) + print(f"Warning: Including .env files in the wheel (use with caution!): {listed}") def pack_project( root: Optional[str] = None, *, dry_run: bool = False, + include_env: bool = False, output_dir: Optional[str] = None, ) -> dict: """Pack the project at ``root``. @@ -462,12 +488,15 @@ def pack_project( root = os.path.abspath(root or os.getcwd()) from nitrostack.cli.vendor import ensure_vendored_nitrostack ensure_vendored_nitrostack(root) - files = collect_pack_files(root) + files = collect_pack_files(root, include_env=include_env) name, version = _project_name_and_version(root) dist_name = _pep503_wheel_name(_normalize_dist_name(name)) planned_wheel = f"{dist_name}-{version}-py3-none-any.whl" dest_dir = os.path.abspath(output_dir or os.path.join(root, "dist")) + if include_env: + _warn_including_env_files(_secret_env_paths_in_pack(files)) + print("Files that would be packed:" if dry_run else "Packing files:") for rel in files: print(f" {rel}") @@ -486,12 +515,17 @@ def pack_project( write_requirements_txt(staging, deps) _ensure_pyproject(staging, name, version, deps) - wheel_path = _build_wheel(staging, wheel_tmp, name, version, deps) + wheel_path = _build_wheel( + staging, wheel_tmp, name, version, deps, include_env=include_env + ) extra = [] env_example = os.path.join(staging, ".env.example") reqs = os.path.join(staging, "requirements.txt") extra.append((".env.example", env_example)) extra.append(("requirements.txt", reqs)) + if include_env: + for rel in _secret_env_paths_in_pack(files): + extra.append((rel, os.path.join(staging, rel.replace("/", os.sep)))) _inject_into_wheel(wheel_path, extra) if not _is_valid_wheel(wheel_path): diff --git a/tests/test_cli.py b/tests/test_cli.py index 95046d9..40b218e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1072,6 +1072,38 @@ def test_pack_dry_run_lists_files_without_writing(tmp_path: Path): assert not (project / "dist").exists() +def test_pack_include_env_dry_run_lists_env_without_printing_contents(tmp_path: Path, capsys): + project = _mini_project(tmp_path) + (project / ".env.local").write_text("TOKEN=local-secret\n", encoding="utf-8") + result = pack_project(str(project), dry_run=True, include_env=True) + assert result["dry_run"] is True + assert ".env" in result["files"] + assert ".env.local" in result["files"] + captured = capsys.readouterr().out + assert "Warning: Including .env files in the wheel (use with caution!)" in captured + assert ".env" in captured + assert ".env.local" in captured + assert "SECRET=should-never-pack" not in captured + assert "TOKEN=local-secret" not in captured + + +def test_pack_include_env_puts_env_in_wheel_without_printing_contents(tmp_path: Path, capsys): + project = _mini_project(tmp_path) + result = pack_project(str(project), dry_run=False, include_env=True) + captured = capsys.readouterr().out + assert "Warning: Including .env files in the wheel (use with caution!)" in captured + assert "SECRET=should-never-pack" not in captured + + wheel = Path(result["wheel"]) + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + assert any(n == ".env" or n.endswith("/.env") for n in names) + env_data = next( + zf.read(n).decode("utf-8") for n in names if n == ".env" or n.endswith("/.env") + ) + assert "SECRET=should-never-pack" in env_data + + def test_pack_creates_valid_wheel(tmp_path: Path): project = _mini_project(tmp_path) result = pack_project(str(project), dry_run=False) From 902d0ddcf7bd6de3978c382e486d4e05a3009ae0 Mon Sep 17 00:00:00 2001 From: Hemant Jadhav Date: Sat, 17 Oct 2026 20:29:06 +0530 Subject: [PATCH 14/16] docs: add Product Hunt featured badge to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0f55682..7deb60d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # NitroStack Python SDK +NitroStack - Build, test, and deploy MCP servers in one place | Product Hunt + Build Model Context Protocol (MCP) servers in Python with a NestJS-style structure: modules, dependency injection, decorators for tools, resources and prompts, and a pipeline of guards, middleware, interceptors, pipes and exception filters. It's the Python port of the [NitroStack](https://docs.nitrostack.ai/) TypeScript framework. - **Docs:** [docs.nitrostack.ai/python](https://docs.nitrostack.ai/python) From d51e202a5ec14b75308fcba09a60f91846fdf900 Mon Sep 17 00:00:00 2001 From: Hemant Jadhav Date: Sun, 18 Oct 2026 11:04:26 +0530 Subject: [PATCH 15/16] docs: update Product Hunt badge to #1 Product of the Day top-post badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7deb60d..c7285ec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NitroStack Python SDK -NitroStack - Build, test, and deploy MCP servers in one place | Product Hunt +NitroStack - Build, test, and deploy MCP servers in one place | Product Hunt Build Model Context Protocol (MCP) servers in Python with a NestJS-style structure: modules, dependency injection, decorators for tools, resources and prompts, and a pipeline of guards, middleware, interceptors, pipes and exception filters. It's the Python port of the [NitroStack](https://docs.nitrostack.ai/) TypeScript framework. From 3ea98a767e01016134fe4d0396518a91b6e7bff5 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 27 Oct 2026 10:02:53 +0530 Subject: [PATCH 16/16] feat: add caller-scoped data spillover for large tool results --- README.md | 90 + docs/spillover-large-payload-results.json | 272 +++ docs/spillover-line-by-line.md | 1566 +++++++++++++++++ nitrostack/__init__.py | 17 + nitrostack/core/app.py | 62 +- nitrostack/core/context.py | 2 + nitrostack/core/errors.py | 4 + nitrostack/core/interceptors/__init__.py | 15 + .../core/interceptors/data_spillover.py | 168 ++ nitrostack/core/interceptors/spillover.py | 218 +++ nitrostack/core/pipeline.py | 9 +- tests/test_spillover.py | 388 ++++ 12 files changed, 2807 insertions(+), 4 deletions(-) create mode 100644 docs/spillover-large-payload-results.json create mode 100644 docs/spillover-line-by-line.md create mode 100644 nitrostack/core/interceptors/__init__.py create mode 100644 nitrostack/core/interceptors/data_spillover.py create mode 100644 nitrostack/core/interceptors/spillover.py create mode 100644 tests/test_spillover.py diff --git a/README.md b/README.md index a122b1a..579f24a 100644 --- a/README.md +++ b/README.md @@ -687,3 +687,93 @@ if __name__ == "__main__": pip install -e ".[dev]" pytest tests ``` + +### Large tool results: data spillover + +Attach `DataSpilloverInterceptor` to a tool to store large results separately and +return a preview, `resource_uri`, and `original_size_bytes`. Results at or below +the threshold pass through unchanged. Sizes count serialized UTF-8 bytes, not +characters; lists and dictionaries use compact JSON. + +```python +from pydantic import BaseModel +from nitrostack import DataSpilloverInterceptor, MemorySpilloverStore, tool, use_interceptors + +spillover = DataSpilloverInterceptor( + threshold_bytes=64 * 1024, + store=MemorySpilloverStore( + max_bytes=10 * 1024 * 1024, + max_total_bytes=100 * 1024 * 1024, + ttl_seconds=300, + ), + resource_uri_prefix="resource://data-spillover/", +) + +class ReportInput(BaseModel): + count: int = 10000 + +class ReportTools: + @tool(name="report", description="Generate a report", input_schema=ReportInput) + @use_interceptors(spillover) + async def report(self, input: ReportInput, context): + return [{"row": index} for index in range(input.count)] +``` + +Register `ReportTools` as a module provider or controller as usual. A configured +interceptor instance works directly in the decorator. For default configuration, +`@use_interceptors(DataSpilloverInterceptor)` also works through dependency injection. + +The MCP result includes a text preview, structured content, and a resource link. +Clients retrieve the full result with `resources/read`, for example: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "resources/read", + "params": {"uri": "resource://data-spillover/"} +} +``` + +Lists and dictionaries preview their first five items or entries and total counts. +Strings preview up to 512 characters. The complete preview stays within 2 KiB; +large nested values are represented as truncated text. Reading the full resource +can consume context again, so clients should retrieve it only when needed. + +For disk storage, replace the store with: + +```python +from nitrostack import FileSpilloverStore + +store = FileSpilloverStore( + ".nitrostack/spillover", + max_bytes=10 * 1024 * 1024, + max_total_bytes=100 * 1024 * 1024, + ttl_seconds=300, +) +``` + +The memory store evicts least recently used results when its payload capacity is +full. The file store rejects writes when its capacity is full; its capacity counts +actual JSON file bytes, including base64 and metadata overhead. A single result +larger than `max_bytes` raises `PayloadTooLargeError`. Both stores remove expired +results during operations; file storage also cleans up on initialization. Expiry +is absolute and reads do not extend it. Each file-store root must be owned by one +store instance in one process; shared storage across processes is not supported. +Keep the configured directory private to the server account. + +Resource access requires the same verified principal, issuer, client identity, +and tenant. Anonymous access is limited to the same server-side MCP session. +Requests without either a verified identity or trusted session cannot spill +results; invalid credentials do not fall back to anonymous access. Anonymous +HTTP clients need a persistent MCP session; sessionless HTTP clients should +authenticate. Expired results and results owned by another caller return +resource-not-found. Stored results are not enumerated in `resources/list`. + +Successful explicit MCP tool results are stored as JSON; errors and input-required +responses pass through. Decorated tools with output schemas advertise either the +original output or the spillover envelope. Spilled widget results return the +resource reference without invoking the widget's data transformer. + +See [the line-by-line implementation walkthrough](docs/spillover-line-by-line.md) +for the storage, interceptor, MCP integration, and tests. diff --git a/docs/spillover-large-payload-results.json b/docs/spillover-large-payload-results.json new file mode 100644 index 0000000..372f407 --- /dev/null +++ b/docs/spillover-large-payload-results.json @@ -0,0 +1,272 @@ +{ + "description": "In-process registered MCP tool/resource handlers, not a network client", + "threshold_bytes": 65536, + "max_payload_bytes": 10485760, + "checks_passed": 24, + "results": [ + { + "backend": "memory", + "case": "threshold_exact_64KiB", + "original_bytes": 65536, + "outcome": "unchanged", + "elapsed_ms": 2.93, + "passed": true + }, + { + "backend": "memory", + "case": "threshold_plus_one", + "original_bytes": 65537, + "outcome": "spilled_and_verified", + "preview_bytes": 577, + "response_bytes": 1773, + "sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "cross_caller_denied": true, + "elapsed_ms": 4.54, + "passed": true + }, + { + "backend": "memory", + "case": "ascii_1MiB", + "original_bytes": 1048576, + "outcome": "spilled_and_verified", + "preview_bytes": 579, + "response_bytes": 1781, + "sha256": "9bc1b2a288b26af7257a36277ae3816a7d4f16e89c1e7e77d0a5c48bad62b360", + "cross_caller_denied": true, + "elapsed_ms": 5.64, + "passed": true + }, + { + "backend": "memory", + "case": "ascii_5MiB", + "original_bytes": 5242880, + "outcome": "spilled_and_verified", + "preview_bytes": 579, + "response_bytes": 1781, + "sha256": "a37b6bc45a8dbe582dd575143facb838ef5a1ae26237a26d382c501eddb75c6f", + "cross_caller_denied": true, + "elapsed_ms": 17.4, + "passed": true + }, + { + "backend": "memory", + "case": "ascii_maximum_10MiB", + "original_bytes": 10485760, + "outcome": "spilled_and_verified", + "preview_bytes": 580, + "response_bytes": 1785, + "sha256": "27b0e54c675579a42826940047ec915d3b282115f6d5636e94e61c060599f5d3", + "cross_caller_denied": true, + "elapsed_ms": 29.67, + "passed": true + }, + { + "backend": "memory", + "case": "emoji_1MiB", + "original_bytes": 1048576, + "outcome": "spilled_and_verified", + "preview_bytes": 1088, + "response_bytes": 2801, + "sha256": "9657fb102a5331c7bf86fb73146da1e764ec73c38a151bedc1da3f647c066cfc", + "cross_caller_denied": true, + "elapsed_ms": 7.48, + "passed": true + }, + { + "backend": "memory", + "case": "list_100000_rows", + "original_bytes": 4788891, + "outcome": "spilled_and_verified", + "preview_bytes": 302, + "response_bytes": 1236, + "sha256": "1dbeae5b14df824da05459b9d5326dbccaa8ec88432a5308b07d101f68d1595a", + "cross_caller_denied": true, + "elapsed_ms": 73.38, + "passed": true + }, + { + "backend": "memory", + "case": "dict_50000_entries", + "original_bytes": 2738891, + "outcome": "spilled_and_verified", + "preview_bytes": 326, + "response_bytes": 1284, + "sha256": "dae161f499a75e532e499eabf2b180db2321d8338cb6406b0bd545528bbed2d0", + "cross_caller_denied": true, + "elapsed_ms": 34.82, + "passed": true + }, + { + "backend": "memory", + "case": "nested_6MiB", + "original_bytes": 6291479, + "outcome": "spilled_and_verified", + "preview_bytes": 576, + "response_bytes": 1785, + "sha256": "30ba6722cb9edbe507a74b0a9a897fa304e28dd94bb118295a5dd84536a1aad1", + "cross_caller_denied": true, + "elapsed_ms": 164.35, + "passed": true + }, + { + "backend": "memory", + "case": "escaped_string_1MiB", + "original_bytes": 1048576, + "outcome": "spilled_and_verified", + "preview_bytes": 1091, + "response_bytes": 3573, + "sha256": "2a884297403f5cbdbc3e25775cc27f85b408c149e2ad8fc5bf3a190a3d82fac3", + "cross_caller_denied": true, + "elapsed_ms": 6.62, + "passed": true + }, + { + "backend": "memory", + "case": "over_maximum_by_one", + "original_bytes": 10485761, + "outcome": "rejected_at_storage_limit", + "elapsed_ms": 18.84, + "passed": true + }, + { + "backend": "memory", + "case": "oversized_20MiB", + "original_bytes": 20971520, + "outcome": "rejected_at_storage_limit", + "elapsed_ms": 16.94, + "passed": true + }, + { + "backend": "file", + "case": "threshold_exact_64KiB", + "original_bytes": 65536, + "outcome": "unchanged", + "elapsed_ms": 0.48, + "passed": true + }, + { + "backend": "file", + "case": "threshold_plus_one", + "original_bytes": 65537, + "outcome": "spilled_and_verified", + "preview_bytes": 577, + "response_bytes": 1773, + "sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "cross_caller_denied": true, + "elapsed_ms": 54.06, + "passed": true + }, + { + "backend": "file", + "case": "ascii_1MiB", + "original_bytes": 1048576, + "outcome": "spilled_and_verified", + "preview_bytes": 579, + "response_bytes": 1781, + "sha256": "9bc1b2a288b26af7257a36277ae3816a7d4f16e89c1e7e77d0a5c48bad62b360", + "cross_caller_denied": true, + "elapsed_ms": 96.76, + "passed": true + }, + { + "backend": "file", + "case": "ascii_5MiB", + "original_bytes": 5242880, + "outcome": "spilled_and_verified", + "preview_bytes": 579, + "response_bytes": 1781, + "sha256": "a37b6bc45a8dbe582dd575143facb838ef5a1ae26237a26d382c501eddb75c6f", + "cross_caller_denied": true, + "elapsed_ms": 328.19, + "passed": true + }, + { + "backend": "file", + "case": "ascii_maximum_10MiB", + "original_bytes": 10485760, + "outcome": "spilled_and_verified", + "preview_bytes": 580, + "response_bytes": 1785, + "sha256": "27b0e54c675579a42826940047ec915d3b282115f6d5636e94e61c060599f5d3", + "cross_caller_denied": true, + "elapsed_ms": 820.87, + "passed": true + }, + { + "backend": "file", + "case": "emoji_1MiB", + "original_bytes": 1048576, + "outcome": "spilled_and_verified", + "preview_bytes": 1088, + "response_bytes": 2801, + "sha256": "9657fb102a5331c7bf86fb73146da1e764ec73c38a151bedc1da3f647c066cfc", + "cross_caller_denied": true, + "elapsed_ms": 556.92, + "passed": true + }, + { + "backend": "file", + "case": "list_100000_rows", + "original_bytes": 4788891, + "outcome": "spilled_and_verified", + "preview_bytes": 302, + "response_bytes": 1236, + "sha256": "1dbeae5b14df824da05459b9d5326dbccaa8ec88432a5308b07d101f68d1595a", + "cross_caller_denied": true, + "elapsed_ms": 846.87, + "passed": true + }, + { + "backend": "file", + "case": "dict_50000_entries", + "original_bytes": 2738891, + "outcome": "spilled_and_verified", + "preview_bytes": 326, + "response_bytes": 1284, + "sha256": "dae161f499a75e532e499eabf2b180db2321d8338cb6406b0bd545528bbed2d0", + "cross_caller_denied": true, + "elapsed_ms": 852.21, + "passed": true + }, + { + "backend": "file", + "case": "nested_6MiB", + "original_bytes": 6291479, + "outcome": "spilled_and_verified", + "preview_bytes": 576, + "response_bytes": 1785, + "sha256": "30ba6722cb9edbe507a74b0a9a897fa304e28dd94bb118295a5dd84536a1aad1", + "cross_caller_denied": true, + "elapsed_ms": 1299.25, + "passed": true + }, + { + "backend": "file", + "case": "escaped_string_1MiB", + "original_bytes": 1048576, + "outcome": "spilled_and_verified", + "preview_bytes": 1091, + "response_bytes": 3573, + "sha256": "2a884297403f5cbdbc3e25775cc27f85b408c149e2ad8fc5bf3a190a3d82fac3", + "cross_caller_denied": true, + "elapsed_ms": 1001.99, + "passed": true + }, + { + "backend": "file", + "case": "over_maximum_by_one", + "original_bytes": 10485761, + "outcome": "rejected_at_storage_limit", + "elapsed_ms": 36.82, + "passed": true + }, + { + "backend": "file", + "case": "oversized_20MiB", + "original_bytes": 20971520, + "outcome": "rejected_at_storage_limit", + "elapsed_ms": 13.4, + "passed": true + } + ] +} \ No newline at end of file diff --git a/docs/spillover-line-by-line.md b/docs/spillover-line-by-line.md new file mode 100644 index 0000000..298e64f --- /dev/null +++ b/docs/spillover-line-by-line.md @@ -0,0 +1,1566 @@ +# Issue #67: line-by-line implementation walkthrough + +This document explains every added or modified Python line, including tests and exports. Existing untouched lines are omitted. Line numbers refer to the final files in this working tree. Blank lines and closing punctuation are identified too, so there are no unexplained gaps inside an added block. + +## Read the feature in this order + +1. **Storage:** the protocol defines what a backend must do; RAM and filesystem classes implement it. +2. **Interceptor:** execute a tool, serialize its result, compare its byte size, save only oversized results, and return a small typed wrapper. +3. **Application integration:** register resource prefixes, capture trusted caller identity, convert the wrapper to MCP, and serve authorized resource reads. +4. **Tests:** exercise storage contracts, preview size, real registered MCP handlers, authentication/session isolation, expiry, tasks, and filesystem rejection. + +## Key ideas before reading individual lines + +- **UTF-8 bytes versus characters:** `é` is one character but two bytes. Limits use the serialized byte length, so multibyte text cannot bypass them. +- **Threshold versus maximum:** 64 KiB is the default point at which a result spills; 10 MiB is the default maximum one stored payload may occupy. The 100 MiB total cap bounds each store. +- **TTL:** a five-minute default lifetime becomes an absolute timestamp. Reads do not refresh it. Expired entries disappear during operations, and file entries are also swept when reopening the store. +- **LRU:** successful memory reads move entries to the end of an OrderedDict. Eviction removes the first remaining key, which is the least recently used. +- **Owner:** a digest of verified identity or a server-created session key. The URI is a lookup address, not proof of authorization. Another caller receives not-found even if they know it. +- **Base64:** makes payload bytes fit into a JSON file without losing information. It is not encryption. File-store roots must be private to the server account and owned by one instance/process. +- **Async and threads:** the public store API is async. RAM operations are short synchronous critical sections; filesystem operations are moved to worker threads. A thread lock protects accounting and atomicity. +- **Typed wrapper:** SpilloverResult is internal and cannot be confused with an ordinary user dictionary. The MCP response contains a preview, URI, byte size, and a resource link, never the original oversized data. +- **Lifecycle:** memory data disappears on restart; unexpired disk data survives reopening. Anonymous ownership ends with its live session, while an authenticated principal can read after reconnecting. +- **Limits of v1:** resources return complete data. There are no partial reads, external cloud storage adapters, or shared file-root coordination across processes. + +## `nitrostack/__init__.py` + +Explains 17 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 3:** `from nitrostack.core.interceptors import (` — Import the named APIs from `nitrostack.core.interceptors` so this file can use the shared SDK, library, or test implementation. +- **Line 4:** `DataSpilloverInterceptor,` — Import or re-export `DataSpilloverInterceptor` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 5:** `FileSpilloverStore,` — Import or re-export `FileSpilloverStore` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 6:** `MemorySpilloverStore,` — Import or re-export `MemorySpilloverStore` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 7:** `PayloadTooLargeError,` — Import or re-export `PayloadTooLargeError` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 8:** `SpilloverEntry,` — Import or re-export `SpilloverEntry` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 9:** `SpilloverStore,` — Import or re-export `SpilloverStore` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 10:** `generate_preview,` — Import or re-export `generate_preview` from `nitrostack.core.interceptors` as part of the import block above. +- **Line 11:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 12:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 152:** `"DataSpilloverInterceptor",` — List `"DataSpilloverInterceptor"` as a supported public export so wildcard imports and API discovery include it. +- **Line 153:** `"FileSpilloverStore",` — List `"FileSpilloverStore"` as a supported public export so wildcard imports and API discovery include it. +- **Line 154:** `"MemorySpilloverStore",` — List `"MemorySpilloverStore"` as a supported public export so wildcard imports and API discovery include it. +- **Line 155:** `"PayloadTooLargeError",` — List `"PayloadTooLargeError"` as a supported public export so wildcard imports and API discovery include it. +- **Line 156:** `"SpilloverEntry",` — List `"SpilloverEntry"` as a supported public export so wildcard imports and API discovery include it. +- **Line 157:** `"SpilloverStore",` — List `"SpilloverStore"` as a supported public export so wildcard imports and API discovery include it. +- **Line 158:** `"generate_preview",` — List `"generate_preview"` as a supported public export so wildcard imports and API discovery include it. + +## `nitrostack/core/app.py` + +Explains 61 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 43:** `from nitrostack.core.interceptors.data_spillover import (` — Import the named APIs from `nitrostack.core.interceptors.data_spillover` so this file can use the shared SDK, library, or test implementation. +- **Line 44:** `DataSpilloverInterceptor, SPILLOVER_SCHEMA, SpilloverRegistry, SpilloverResult,` — Import or re-export `DataSpilloverInterceptor, SPILLOVER_SCHEMA, SpilloverRegistry, SpilloverResult` from `nitrostack.core.interceptors.data_spillover` as part of the import block above. +- **Line 45:** `bind_spillover_context, caller_owner,` — Import or re-export `bind_spillover_context, caller_owner` from `nitrostack.core.interceptors.data_spillover` as part of the import block above. +- **Line 46:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +### `McpApplication.__init__` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 605:** `self._spillover = SpilloverRegistry()` — Set `self._spillover` to the application-owned registry shared by its tool and resource handlers: call `SpilloverRegistry` with the arguments shown to perform the current operation. +### `McpApplication._bootstrap` + +This addition discovers opted-in tool interceptors after providers are ready and registers their stores before MCP handlers are exposed. + +- **Line 677:** `for entry in self._tools.values():` — Iterate `entry` through `self._tools.values()`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 678:** `for candidate in getattr(entry.method, "_mcp_interceptors", []):` — Iterate `candidate` through `getattr(entry.method, '_mcp_interceptors', [])`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 679:** `interceptor = container.resolve(candidate) if isinstance(candidate, type) else candidate` — Set `interceptor`: select `container.resolve(candidate)` when `isinstance(candidate, type)` is true; otherwise select `candidate`. +- **Line 680:** `if isinstance(interceptor, DataSpilloverInterceptor):` — Check check the runtime shape before selecting the appropriate serialization or control path. The indented body handles this case; other results continue past it. +- **Line 681:** `self._spillover.register(interceptor)` — Bind this interceptor's uri prefix to its configured store in the application-owned resource registry. +### `McpApplication._build_tool_definition` + +This addition allows a decorated tool to advertise its original output shape or the spillover envelope. Root-level definitions are preserved so existing schema references keep resolving. + +- **Line 1127:** `if self._has_spillover(entry.method):` — Check check whether this tool has the spillover interceptor before advertising an additional valid result shape. The indented body handles this case; other results continue past it. +- **Line 1128:** `output_schema = {` — Set `output_schema`: evaluate `{'type': 'object', 'anyOf': [output_schema, SPILLOVER_SCHEMA], **({'$defs': output_schema['$defs']} if '$defs' in output_schema else {})}`. +- **Line 1129:** `"type": "object", "anyOf": [output_schema, SPILLOVER_SCHEMA],` — Continue building `output_schema` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 1130:** `**({"$defs": output_schema["$defs"]} if "$defs" in output_schema else {}),` — Continue building `output_schema` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 1131:** `}` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +### `McpApplication._has_spillover` + +Inspect the interceptors attached to a tool, resolving class-based registrations through the existing dependency-injection container. This determines whether the advertised output schema must also allow the spillover envelope. + +- **Line 1176:** `def _has_spillover(self, method: Callable) -> bool:` — Define this callable. Inspect the interceptors attached to a tool, resolving class-based registrations through the existing dependency-injection container. This determines whether the advertised output schema must also allow the spillover envelope. +- **Line 1177:** `container = DIContainer.get_instance()` — Set `container`: call `DIContainer.get_instance` with the arguments shown to perform the current operation. +- **Line 1178:** `return any(` — Return from this operation: call `any` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 1179:** `isinstance(container.resolve(item) if isinstance(item, type) else item,` — Return from this operation: call `any` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 1180:** `DataSpilloverInterceptor)` — Return from this operation: call `any` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 1181:** `for item in getattr(method, "_mcp_interceptors", [])` — Return from this operation: call `any` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 1182:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 1183:** `(blank)` — Blank separator for readability; it performs no operation. +### `McpApplication._bind_spillover` + +Prepare caller information only when this application has spillover routes. Reuse the SDK verified JWT context, or introspect an opaque token through an already configured OAuthService, then bind anonymous-session and failed-authentication state. + +- **Line 1184:** `async def _bind_spillover(self, context: ExecutionContext, rc: Any) -> None:` — Define an asynchronous operation that callers await. Prepare caller information only when this application has spillover routes. Reuse the SDK verified JWT context, or introspect an opaque token through an already configured OAuthService, then bind anonymous-session and failed-authentication state. +- **Line 1185:** `if not self._spillover.routes:` — Check evaluate `not self._spillover.routes`. The indented body handles this case; other results continue past it. +- **Line 1186:** `return` — Return from this operation: evaluate `None`. No following statements in this call execute. +- **Line 1187:** `# OAuth guards may verify opaque tokens as well as the shared JWT path.` — Comment explaining the intent of the following operation; Python does not execute it. +- **Line 1188:** `from nitrostack.auth.request import authorization_token_from_request, auth_context_from_payload` — Import the named APIs from `nitrostack.auth.request` so this file can use the shared SDK, library, or test implementation. +- **Line 1189:** `from nitrostack.auth.oauth import OAuthService` — Import the named APIs from `nitrostack.auth.oauth` so this file can use the shared SDK, library, or test implementation. +- **Line 1190:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 1191:** `token = authorization_token_from_request(rc)` — Set `token`: extract a bearer token using the SDK's established header/envelope precedence without trusting unsigned identity fields. +- **Line 1192:** `container = DIContainer.get_instance()` — Set `container`: call `DIContainer.get_instance` with the arguments shown to perform the current operation. +- **Line 1193:** `if context.auth is None and token and container.has_value(OAuthService):` — Check evaluate `context.auth is None and token and container.has_value(OAuthService)`. The indented body handles this case; other results continue past it. +- **Line 1194:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 1195:** `payload = await container.resolve(OAuthService).introspect_token(token)` — Set `payload` to the complete serialized result as immutable bytes: await ask the configured OAuth service to verify the opaque token and return claims. +- **Line 1196:** `if payload.get("active"):` — Check look up the requested entry under the supplied ownership constraint. The indented body handles this case; other results continue past it. +- **Line 1197:** `context.auth = auth_context_from_payload(payload)` — Set `context.auth`: convert verified JWT or introspection claims into the common AuthContext used by tool and resource handlers. +- **Line 1198:** `except Exception:` — Handle only the exception types shown; the following body translates the failure or applies the documented fallback. +- **Line 1199:** `pass # Invalid credentials remain denied by caller_owner.` — Perform no operation in this intentionally empty class or handled-error branch. +- **Line 1200:** `bind_spillover_context(context, rc, self._spillover)` — Bind trusted session information and the invalid-token marker onto this execution context. +- **Line 1201:** `(blank)` — Blank separator for readability; it performs no operation. +### `McpApplication._pipeline_stages` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 1202:** `def _pipeline_stages(self, method: Callable) -> Tuple[List[Any], List[Any], List[Any], List[Any], List[Any]]:` — Define this callable. Its body below carries out the surrounding operation; this line defines it without running it. +### `McpApplication._to_call_tool_result` + +The new early return converts a spillover wrapper directly to MCP, before ordinary result and widget transformation logic can reinterpret it. + +- **Line 1217:** `if isinstance(result, SpilloverResult):` — Check check the runtime shape before selecting the appropriate serialization or control path. The indented body handles this case; other results continue past it. +- **Line 1218:** `return result.to_mcp()` — Return from this operation: convert only the internal spillover wrapper into the small MCP envelope and resource link, bypassing ordinary widget data handling. No following statements in this call execute. +### `McpApplication._call_tool.background_execution` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 1417:** `await self._bind_spillover(task_ctx, rc)` — Await resolve configured oauth identity when necessary and bind the trusted session/invalid-auth marker for spillover access checks. +### `McpApplication._call_tool` + +The added bindings capture trusted caller/session information for ordinary calls and background tasks before their interceptors execute. + +- **Line 1472:** `await self._bind_spillover(ctx, rc)` — Await resolve configured oauth identity when necessary and bind the trusted session/invalid-auth marker for spillover access checks. +### `McpApplication._read_resource` + +The added route dispatch resolves spillover URIs first, rebuilds verified caller information, checks ownership through the store, and returns the exact saved bytes as text with their MIME type. Missing, invalid, expired, and unauthorized entries become ResourceNotFoundError. + +- **Line 1505:** `for prefix, store in self._spillover.routes.items():` — Iterate `(prefix, store)` through `self._spillover.routes.items()`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 1506:** `if uri.startswith(prefix):` — Check call `uri.startswith` with the arguments shown to perform the current operation. The indented body handles this case; other results continue past it. +- **Line 1507:** `ctx = ExecutionContext(request_id=str(uuid.uuid4()))` — Set `ctx`: construct a fresh per-call context; its request ID identifies the operation while identity is populated separately from trusted request sources. +- **Line 1508:** `rc = request_ctx.get(None)` — Set `rc`: read the current request from the ContextVar, using None if no request is bound; this does not read a stored payload. +- **Line 1509:** `_apply_request_envelope(ctx, rc)` — Populate the execution context with verified jwt identity and existing mcp request metadata through the sdk's shared request binding. +- **Line 1510:** `await self._bind_spillover(ctx, rc)` — Await resolve configured oauth identity when necessary and bind the trusted session/invalid-auth marker for spillover access checks. +- **Line 1511:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 1512:** `entry = await store.get(uri[len(prefix):], owner=caller_owner(ctx))` — Set `entry`: await look up the requested entry under the supplied ownership constraint. +- **Line 1513:** `except (PermissionError, ValueError):` — Handle only the exception types shown; the following body translates the failure or applies the documented fallback. +- **Line 1514:** `raise ResourceNotFoundError(uri) from None` — Stop this operation and use the existing SDK not-found error without revealing whether a different owner has the requested data. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 1515:** `if entry is None:` — Check evaluate `entry is None`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 1516:** `raise ResourceNotFoundError(uri)` — Stop this operation and use the existing SDK not-found error without revealing whether a different owner has the requested data. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 1517:** `return [ReadResourceContents(` — Return from this operation: evaluate `[ReadResourceContents(content=entry.payload.decode('utf-8'), mime_type=entry.mime_type)]`. No following statements in this call execute. +- **Line 1518:** `content=entry.payload.decode("utf-8"), mime_type=entry.mime_type,` — Return from this operation: evaluate `[ReadResourceContents(content=entry.payload.decode('utf-8'), mime_type=entry.mime_type)]`. No following statements in this call execute. +- **Line 1519:** `)]` — Return from this operation: evaluate `[ReadResourceContents(content=entry.payload.decode('utf-8'), mime_type=entry.mime_type)]`. No following statements in this call execute. + +## `nitrostack/core/context.py` + +Explains 2 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `ExecutionContext` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 200:** `spillover_session_key: Optional[str] = field(default=None, repr=False)` — Set `spillover_session_key` to the private session identity carried into the interceptor: declare a dataclass field with the displayed default; repr=False keeps private session/auth markers out of generated representations. +- **Line 201:** `spillover_invalid_auth: bool = field(default=False, repr=False)` — Set `spillover_invalid_auth` to the marker preventing invalid credentials from falling back to anonymous access: declare a dataclass field with the displayed default; repr=False keeps private session/auth markers out of generated representations. + +## `nitrostack/core/errors.py` + +Explains 4 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `PayloadTooLargeError` + +A distinct ValueError subtype for the individual-payload limit. Applications can catch this specific condition separately from a full store or invalid configuration. + +- **Line 4:** `class PayloadTooLargeError(ValueError):` — Define this class. A distinct ValueError subtype for the individual-payload limit. Applications can catch this specific condition separately from a full store or invalid configuration. +- **Line 5:** `"""A serialized tool result exceeds the spillover store's per-result limit."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 6:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 7:** `(blank)` — Blank separator for readability; it performs no operation. + +## `nitrostack/core/interceptors/__init__.py` + +Explains 15 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 1:** `"""Built-in tool interceptors and spillover storage adapters."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 2:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 3:** `from .data_spillover import DataSpilloverInterceptor, generate_preview` — Import the named APIs from `data_spillover` so this file can use the shared SDK, library, or test implementation. +- **Line 4:** `from .spillover import (` — Import the named APIs from `spillover` so this file can use the shared SDK, library, or test implementation. +- **Line 5:** `FileSpilloverStore,` — Import or re-export `FileSpilloverStore` from `spillover` as part of the import block above. +- **Line 6:** `MemorySpilloverStore,` — Import or re-export `MemorySpilloverStore` from `spillover` as part of the import block above. +- **Line 7:** `PayloadTooLargeError,` — Import or re-export `PayloadTooLargeError` from `spillover` as part of the import block above. +- **Line 8:** `SpilloverEntry,` — Import or re-export `SpilloverEntry` from `spillover` as part of the import block above. +- **Line 9:** `SpilloverStore,` — Import or re-export `SpilloverStore` from `spillover` as part of the import block above. +- **Line 10:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 11:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 12:** `__all__ = [` — Set `__all__`: evaluate `['DataSpilloverInterceptor', 'FileSpilloverStore', 'MemorySpilloverStore', 'PayloadTooLargeError', 'SpilloverEntry', 'SpilloverStore', 'generate_preview']`. +- **Line 13:** `"DataSpilloverInterceptor", "FileSpilloverStore", "MemorySpilloverStore",` — List `"DataSpilloverInterceptor", "FileSpilloverStore", "MemorySpilloverStore"` as a supported public export so wildcard imports and API discovery include it. +- **Line 14:** `"PayloadTooLargeError", "SpilloverEntry", "SpilloverStore", "generate_preview",` — List `"PayloadTooLargeError", "SpilloverEntry", "SpilloverStore", "generate_preview"` as a supported public export so wildcard imports and API discovery include it. +- **Line 15:** `]` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. + +## `nitrostack/core/interceptors/data_spillover.py` + +Explains 168 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 1:** `"""Tool-result spillover, bounded previews, and trusted caller ownership."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 2:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 3:** `from __future__ import annotations` — Import the named APIs from `__future__` so this file can use the shared SDK, library, or test implementation. +- **Line 4:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 5:** `import hashlib` — Import hashlib for the operations described below; importing binds the module names without calling their functions. +- **Line 6:** `import json` — Import json for the operations described below; importing binds the module names without calling their functions. +- **Line 7:** `import uuid` — Import uuid for the operations described below; importing binds the module names without calling their functions. +- **Line 8:** `import weakref` — Import weakref for the operations described below; importing binds the module names without calling their functions. +- **Line 9:** `from dataclasses import dataclass` — Import the named APIs from `dataclasses` so this file can use the shared SDK, library, or test implementation. +- **Line 10:** `from itertools import islice` — Import the named APIs from `itertools` so this file can use the shared SDK, library, or test implementation. +- **Line 11:** `from typing import Any` — Import the named APIs from `typing` so this file can use the shared SDK, library, or test implementation. +- **Line 12:** `from urllib.parse import urlsplit` — Import the named APIs from `urllib.parse` so this file can use the shared SDK, library, or test implementation. +- **Line 13:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 14:** `import mcp.types as types` — Import mcp.types for the operations described below; importing binds the module names without calling their functions. +- **Line 15:** `from pydantic import BaseModel` — Import the named APIs from `pydantic` so this file can use the shared SDK, library, or test implementation. +- **Line 16:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 17:** `from nitrostack.auth.request import authorization_token_from_request, tenant_from_claims` — Import the named APIs from `nitrostack.auth.request` so this file can use the shared SDK, library, or test implementation. +- **Line 18:** `from nitrostack.core.context import ExecutionContext` — Import the named APIs from `nitrostack.core.context` so this file can use the shared SDK, library, or test implementation. +- **Line 19:** `from nitrostack.protocol.mrtr import InputRequiredResult` — Import the named APIs from `nitrostack.protocol.mrtr` so this file can use the shared SDK, library, or test implementation. +- **Line 20:** `from nitrostack.core.interceptors.spillover import MemorySpilloverStore, SpilloverStore` — Import the named APIs from `nitrostack.core.interceptors.spillover` so this file can use the shared SDK, library, or test implementation. +- **Line 21:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 22:** `(blank)` — Blank separator for readability; it performs no operation. +### `generate_preview` + +Build a small description of the original result: type, total count, and either five head elements or a string prefix. If nested values make that description too large, replace them with shortened text and repeatedly halve it until the serialized UTF-8 preview fits its budget. + +- **Line 23:** `def generate_preview(value: Any, *, max_bytes: int = 2048) -> dict:` — Define this callable. Build a small description of the original result: type, total count, and either five head elements or a string prefix. If nested values make that description too large, replace them with shortened text and repeatedly halve it until the serialized UTF-8 preview fits its budget. +- **Line 24:** `"""Include head items and counts, shrinking even deeply nested values to fit."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 25:** `if max_bytes < 128:` — Check evaluate `max_bytes < 128`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 26:** `raise ValueError("Preview budget must be at least 128 bytes")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 27:** `if isinstance(value, list):` — Check check the runtime shape before selecting the appropriate serialization or control path. The indented body handles this case; other results continue past it. +- **Line 28:** `preview = {"type": "list", "count": len(value), "head": value[:5], "truncated": len(value) > 5}` — Set `preview`: evaluate `{'type': 'list', 'count': len(value), 'head': value[:5], 'truncated': len(value) > 5}`. +- **Line 29:** `elif isinstance(value, dict):` — Test this next case only if previous branches did not match; the condition shown selects the appropriate handling. +- **Line 30:** `preview = {"type": "dict", "count": len(value), "head": dict(islice(value.items(), 5)),` — Set `preview`: evaluate `{'type': 'dict', 'count': len(value), 'head': dict(islice(value.items(), 5)), 'truncated': len(value) > 5}`. +- **Line 31:** `"truncated": len(value) > 5}` — Continue building `preview` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 32:** `else:` — Enter the alternative path only when the preceding condition did not match. +- **Line 33:** `text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)` — Set `text`: select `value` when `isinstance(value, str)` is true; otherwise select `json.dumps(value, ensure_ascii=False, default=str)`. +- **Line 34:** `preview = {"type": "string", "count": len(text), "text": text[:512], "truncated": len(text) > 512}` — Set `preview`: evaluate `{'type': 'string', 'count': len(text), 'text': text[:512], 'truncated': len(text) > 512}`. +- **Line 35:** `encoded = json.dumps(preview, ensure_ascii=False, default=str).encode("utf-8")` — Set `encoded`: convert text into the selected byte encoding, so storage and limits operate on bytes. +- **Line 36:** `if len(encoded) > max_bytes:` — Check evaluate `len(encoded) > max_bytes`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 37:** `text = json.dumps(preview.get("head", preview.get("text")), ensure_ascii=False, default=str)` — Set `text`: serialize the value to JSON text; ensure_ascii=False preserves Unicode and UTF-8 encoding determines the actual byte count. +- **Line 38:** `preview.pop("head", None)` — Remove the named mapping entry and return it when needed. +- **Line 39:** `preview["text"] = text[:512]` — Set `preview['text']`: evaluate `text[:512]`. +- **Line 40:** `preview["truncated"] = True` — Set `preview['truncated']`: use the literal `True`. +- **Line 41:** `while len(json.dumps(preview, ensure_ascii=False, default=str).encode("utf-8")) > max_bytes:` — Repeat the indented operation while `len(json.dumps(preview, ensure_ascii=False, default=str).encode('utf-8')) > max_bytes` remains true; each iteration reduces the preview or frees capacity so the loop can finish. +- **Line 42:** `preview["text"] = preview["text"][:len(preview["text"]) // 2]` — Set `preview['text']`: evaluate `preview['text'][:len(preview['text']) // 2]`. +- **Line 43:** `return preview` — Return from this operation: evaluate `preview`. No following statements in this call execute. +- **Line 44:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 45:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverResult` + +This internal wrapper distinguishes an actual spillover response from an ordinary dictionary returned by a tool. That distinction lets the application bypass widget data transformation and produce the correct MCP resource link. + +- **Line 46:** `@dataclass(frozen=True)` — Generate an immutable data container: field initialization and comparisons are provided, while frozen=True prevents field reassignment. +- **Line 47:** `class SpilloverResult:` — Define this class. This internal wrapper distinguishes an actual spillover response from an ordinary dictionary returned by a tool. That distinction lets the application bypass widget data transformation and produce the correct MCP resource link. +- **Line 48:** `preview: dict` — Declare `preview` as `dict` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 49:** `resource_uri: str` — Declare `resource_uri` as `str` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 50:** `original_size_bytes: int` — Declare `original_size_bytes` as `int` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 51:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverResult.to_mcp` + +Translate the internal wrapper into an MCP CallToolResult. The same small envelope is available as text and structured data; a separate resource_link points to the complete data without embedding it. + +- **Line 52:** `def to_mcp(self) -> types.CallToolResult:` — Define this callable. Translate the internal wrapper into an MCP CallToolResult. The same small envelope is available as text and structured data; a separate resource_link points to the complete data without embedding it. +- **Line 53:** `data = dict(preview=self.preview, resource_uri=self.resource_uri,` — Set `data`: call `dict` with the arguments shown to perform the current operation. +- **Line 54:** `original_size_bytes=self.original_size_bytes)` — Continue building `data` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 55:** `return types.CallToolResult(` — Return from this operation: construct the official MCP tool-result model; wire aliases such as structuredContent are handled by that model. No following statements in this call execute. +- **Line 56:** `content=[` — Return from this operation: construct the official MCP tool-result model; wire aliases such as structuredContent are handled by that model. No following statements in this call execute. +- **Line 57:** `types.TextContent(type="text", text=json.dumps(data, ensure_ascii=False, default=str)),` — Return from this operation: construct the official MCP tool-result model; wire aliases such as structuredContent are handled by that model. No following statements in this call execute. +- **Line 58:** `types.ResourceLink(type="resource_link", uri=self.resource_uri,` — Return from this operation: construct the official MCP tool-result model; wire aliases such as structuredContent are handled by that model. No following statements in this call execute. +- **Line 59:** `name="Full tool result"),` — Return from this operation: construct the official MCP tool-result model; wire aliases such as structuredContent are handled by that model. No following statements in this call execute. +- **Line 60:** `],` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 61:** `structuredContent=data, isError=False,` — Return from this operation: construct the official MCP tool-result model; wire aliases such as structuredContent are handled by that model. No following statements in this call execute. +- **Line 62:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 63:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 64:** `(blank)` — Blank separator for readability; it performs no operation. +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 65:** `SPILLOVER_SCHEMA = {` — Set `SPILLOVER_SCHEMA`: evaluate `{'type': 'object', 'properties': {'preview': {'type': 'object'}, 'resource_uri': {'type': 'string'}, 'original_size_bytes': {'type': 'integer', 'minimum': 0}}, 'required': ['preview', 'resource_uri', 'original_size_bytes']}`. +- **Line 66:** `"type": "object",` — Continue building `SPILLOVER_SCHEMA` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 67:** `"properties": {` — Continue building `SPILLOVER_SCHEMA` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 68:** `"preview": {"type": "object"},` — Continue building `SPILLOVER_SCHEMA` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 69:** `"resource_uri": {"type": "string"},` — Continue building `SPILLOVER_SCHEMA` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 70:** `"original_size_bytes": {"type": "integer", "minimum": 0},` — Continue building `SPILLOVER_SCHEMA` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 71:** `},` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 72:** `"required": ["preview", "resource_uri", "original_size_bytes"],` — Continue building `SPILLOVER_SCHEMA` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 73:** `}` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 74:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 75:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverRegistry` + +Keep resource-prefix-to-store routing private to one application. Anonymous session keys use weak references so this registry does not keep disconnected session objects alive. + +- **Line 76:** `class SpilloverRegistry:` — Define this class. Keep resource-prefix-to-store routing private to one application. Anonymous session keys use weak references so this registry does not keep disconnected session objects alive. +- **Line 77:** `"""Per-application routes and weakly held anonymous session identities."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 78:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverRegistry.__init__` + +Create separate maps for resource routes and weakly referenced anonymous sessions; neither map is global across applications. + +- **Line 79:** `def __init__(self):` — Define this callable. Create separate maps for resource routes and weakly referenced anonymous sessions; neither map is global across applications. +- **Line 80:** `self.routes: dict[str, SpilloverStore] = {}` — Set `self.routes` to the application-private URI-prefix-to-store map: evaluate `{}`. +- **Line 81:** `self._sessions: dict[int, tuple[weakref.ref, str]] = {}` — Set `self._sessions` to the map from live session object IDs to weak references and random ownership keys: evaluate `{}`. +- **Line 82:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverRegistry.register` + +Register a prefix and its store while rejecting ambiguous overlapping prefixes. Reusing exactly the same prefix and store is allowed because several tools may share one configured interceptor. + +- **Line 83:** `def register(self, interceptor):` — Define this callable. Register a prefix and its store while rejecting ambiguous overlapping prefixes. Reusing exactly the same prefix and store is allowed because several tools may share one configured interceptor. +- **Line 84:** `prefix, store = interceptor.resource_uri_prefix, interceptor.store` — Set `(prefix, store)`: evaluate `(interceptor.resource_uri_prefix, interceptor.store)`. +- **Line 85:** `for existing, registered in self.routes.items():` — Iterate `(existing, registered)` through `self.routes.items()`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 86:** `if prefix.startswith(existing) or existing.startswith(prefix):` — Check evaluate `prefix.startswith(existing) or existing.startswith(prefix)`. The indented body handles this case; other results continue past it. +- **Line 87:** `if prefix != existing or store is not registered:` — Check evaluate `prefix != existing or store is not registered`. The indented body handles this case; other results continue past it. +- **Line 88:** `raise ValueError("Conflicting spillover resource prefixes")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 89:** `self.routes[prefix] = store` — Set `self.routes[prefix]`: evaluate `store`. +- **Line 90:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverRegistry.session_key` + +Assign a random server-generated key to a live session object, reusing it for later requests on that object. Client-provided session names and identity metadata are never used as ownership proof. + +- **Line 91:** `def session_key(self, rc):` — Define this callable. Assign a random server-generated key to a live session object, reusing it for later requests on that object. Client-provided session names and identity metadata are never used as ownership proof. +- **Line 92:** `session = getattr(rc, "session", None)` — Set `session`: call `getattr` with the arguments shown to perform the current operation. +- **Line 93:** `if session is None:` — Check evaluate `session is None`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 94:** `return None` — Return from this operation: use the literal `None`. No following statements in this call execute. +- **Line 95:** `identity = id(session)` — Set `identity`: call `id` with the arguments shown to perform the current operation. +- **Line 96:** `existing = self._sessions.get(identity)` — Set `existing`: look up a weakly referenced session record using the actual session object's identity. +- **Line 97:** `if existing is not None and existing[0]() is session:` — Check evaluate `existing is not None and existing[0]() is session`. The indented body handles this case; other results continue past it. +- **Line 98:** `return existing[1]` — Return from this operation: evaluate `existing[1]`. No following statements in this call execute. +- **Line 99:** `key = uuid.uuid4().hex` — Set `key`: evaluate `uuid.uuid4().hex`. +- **Line 100:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 101:** `reference = weakref.ref(session, lambda _: self._sessions.pop(identity, None))` — Set `reference`: retain a non-owning reference and install cleanup when the session is garbage-collected. +- **Line 102:** `except TypeError:` — Handle only the exception types shown; the following body translates the failure or applies the documented fallback. +- **Line 103:** `return None` — Return from this operation: use the literal `None`. No following statements in this call execute. +- **Line 104:** `self._sessions[identity] = (reference, key)` — Set `self._sessions[identity]`: evaluate `(reference, key)`. +- **Line 105:** `return key` — Return from this operation: evaluate `key`. No following statements in this call execute. +- **Line 106:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 107:** `(blank)` — Blank separator for readability; it performs no operation. +### `caller_owner` + +Build a SHA-256 fingerprint from trusted identity dimensions. Authenticated ownership includes issuer, subject, client identity, and tenant; otherwise use the private session key. Invalid credentials and absent trusted identity fail closed. + +- **Line 108:** `def caller_owner(context: ExecutionContext) -> str:` — Define this callable. Build a SHA-256 fingerprint from trusted identity dimensions. Authenticated ownership includes issuer, subject, client identity, and tenant; otherwise use the private session key. Invalid credentials and absent trusted identity fail closed. +- **Line 109:** `auth = context.auth` — Set `auth`: evaluate `context.auth`. +- **Line 110:** `if auth is not None and (auth.subject or auth.client_id):` — Check evaluate `auth is not None and (auth.subject or auth.client_id)`. The indented body handles this case; other results continue past it. +- **Line 111:** `identity = ["principal", auth.iss, auth.subject, auth.client_id,` — Set `identity`: evaluate `['principal', auth.iss, auth.subject, auth.client_id, tenant_from_claims(auth.claims)]`. +- **Line 112:** `tenant_from_claims(auth.claims)]` — Continue building `identity` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 113:** `elif context.spillover_invalid_auth:` — Test this next case only if previous branches did not match; the condition shown selects the appropriate handling. +- **Line 114:** `raise PermissionError("Verified identity required for spillover")` — Stop this operation and signal that a trusted caller identity could not be established. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 115:** `elif context.spillover_session_key:` — Test this next case only if previous branches did not match; the condition shown selects the appropriate handling. +- **Line 116:** `identity = ["session", context.spillover_session_key]` — Set `identity`: evaluate `['session', context.spillover_session_key]`. +- **Line 117:** `else:` — Enter the alternative path only when the preceding condition did not match. +- **Line 118:** `raise PermissionError("Spillover requires a verified caller or trusted MCP session")` — Stop this operation and signal that a trusted caller identity could not be established. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 119:** `return hashlib.sha256(json.dumps(identity).encode("utf-8")).hexdigest()` — Return from this operation: return the SHA-256 digest as a stable hexadecimal owner identifier. No following statements in this call execute. +- **Line 120:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 121:** `(blank)` — Blank separator for readability; it performs no operation. +### `bind_spillover_context` + +Copy the registry-derived session key into the execution context and mark a supplied-but-unverified token. The interceptor runs after guards, so an auth guard can still establish verified identity before ownership is resolved. + +- **Line 122:** `def bind_spillover_context(context, rc, registry):` — Define this callable. Copy the registry-derived session key into the execution context and mark a supplied-but-unverified token. The interceptor runs after guards, so an auth guard can still establish verified identity before ownership is resolved. +- **Line 123:** `context.spillover_session_key = registry.session_key(rc)` — Set `context.spillover_session_key`: obtain the server-generated anonymous ownership key for this live session, or None when no supported session is available. +- **Line 124:** `context.spillover_invalid_auth = bool(` — Set `context.spillover_invalid_auth`: call `bool` with the arguments shown to perform the current operation. +- **Line 125:** `authorization_token_from_request(rc) and context.auth is None` — Continue building `context.spillover_invalid_auth` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 126:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 127:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 128:** `(blank)` — Blank separator for readability; it performs no operation. +### `DataSpilloverInterceptor` + +The opt-in interceptor waits for a tool result, preserves control/error responses, serializes successful data, and compares its UTF-8 size with the configured threshold. Only an oversized successful result is stored and replaced with a preview wrapper. + +- **Line 129:** `class DataSpilloverInterceptor:` — Define this class. The opt-in interceptor waits for a tool result, preserves control/error responses, serializes successful data, and compares its UTF-8 size with the configured threshold. Only an oversized successful result is stored and replaced with a preview wrapper. +### `DataSpilloverInterceptor.__init__` + +Validate the threshold and absolute URI prefix, preserve the supplied store, and allocate a default bounded RAM store only when none was supplied. + +- **Line 130:** `def __init__(` — Define this callable. Validate the threshold and absolute URI prefix, preserve the supplied store, and allocate a default bounded RAM store only when none was supplied. +- **Line 131:** `self, threshold_bytes: int = 64 * 1024, store: SpilloverStore | None = None,` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 132:** `resource_uri_prefix: str = "resource://data-spillover/",` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 133:** `):` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 134:** `if type(threshold_bytes) is not int or threshold_bytes < 0:` — Check evaluate `type(threshold_bytes) is not int or threshold_bytes < 0`. The indented body handles this case; other results continue past it. +- **Line 135:** `raise ValueError("Spillover threshold must be a nonnegative integer")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 136:** `parts = urlsplit(resource_uri_prefix)` — Set `parts`: parse the configured resource prefix into URI components so relative addresses, query strings, and fragments can be rejected. +- **Line 137:** `if (not parts.scheme or not parts.netloc or parts.query or parts.fragment` — Check evaluate `not parts.scheme or not parts.netloc or parts.query or parts.fragment or (not resource_uri_prefix.endswith('/'))`. The indented body handles this case; other results continue past it. +- **Line 138:** `or not resource_uri_prefix.endswith("/")):` — Check evaluate `not parts.scheme or not parts.netloc or parts.query or parts.fragment or (not resource_uri_prefix.endswith('/'))`. The indented body handles this case; other results continue past it. +- **Line 139:** `raise ValueError("Resource prefix must be an absolute URI ending in '/'")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 140:** `self.threshold_bytes = threshold_bytes` — Set `self.threshold_bytes` to the boundary above which a successful result spills: evaluate `threshold_bytes`. +- **Line 141:** `self.store = store if store is not None else MemorySpilloverStore()` — Set `self.store` to the storage backend selected for this interceptor: select `store` when `store is not None` is true; otherwise select `MemorySpilloverStore()`. +- **Line 142:** `self.resource_uri_prefix = resource_uri_prefix` — Set `self.resource_uri_prefix` to the prefix prepended to generated store IDs: evaluate `resource_uri_prefix`. +- **Line 143:** `(blank)` — Blank separator for readability; it performs no operation. +### `DataSpilloverInterceptor.intercept` + +This is the interceptor execution path. It calls the next pipeline stage once, returns small results by identity, stores oversized results under the caller fingerprint, then returns the preview/URI/original-size wrapper. + +- **Line 144:** `async def intercept(self, context, next_fn):` — Define an asynchronous operation that callers await. This is the interceptor execution path. It calls the next pipeline stage once, returns small results by identity, stores oversized results under the caller fingerprint, then returns the preview/URI/original-size wrapper. +- **Line 145:** `result = await next_fn()` — Set `result`: await execute the next interceptor or the actual tool handler exactly once and obtain its result. +- **Line 146:** `if isinstance(result, (InputRequiredResult, SpilloverResult)):` — Check check the runtime shape before selecting the appropriate serialization or control path. The indented body handles this case; other results continue past it. +- **Line 147:** `return result` — Return from this operation: evaluate `result`. No following statements in this call execute. +- **Line 148:** `if isinstance(result, dict) and result.get("resultType") == "input_required":` — Check evaluate `isinstance(result, dict) and result.get('resultType') == 'input_required'`. The indented body handles this case; other results continue past it. +- **Line 149:** `return result` — Return from this operation: evaluate `result`. No following statements in this call execute. +- **Line 150:** `value = result` — Set `value`: evaluate `result`. +- **Line 151:** `if isinstance(value, types.CallToolResult):` — Check check the runtime shape before selecting the appropriate serialization or control path. The indented body handles this case; other results continue past it. +- **Line 152:** `if value.is_error or value.result_type == "input_required":` — Check evaluate `value.is_error or value.result_type == 'input_required'`. The indented body handles this case; other results continue past it. +- **Line 153:** `return result` — Return from this operation: evaluate `result`. No following statements in this call execute. +- **Line 154:** `value = value.model_dump(mode="json", by_alias=True, exclude_none=True)` — Set `value`: convert the Pydantic model into JSON-compatible data, using the requested wire aliases. +- **Line 155:** `elif isinstance(value, dict) and value.get("isError"):` — Test this next case only if previous branches did not match; the condition shown selects the appropriate handling. +- **Line 156:** `return result` — Return from this operation: evaluate `result`. No following statements in this call execute. +- **Line 157:** `elif isinstance(value, BaseModel):` — Test this next case only if previous branches did not match; the condition shown selects the appropriate handling. +- **Line 158:** `value = value.model_dump(mode="json")` — Set `value`: convert the Pydantic model into JSON-compatible data, using the requested wire aliases. +- **Line 159:** `if isinstance(value, str):` — Check check the runtime shape before selecting the appropriate serialization or control path. The indented body handles this case; other results continue past it. +- **Line 160:** `payload, mime = value.encode("utf-8"), "text/plain"` — Set `(payload, mime)`: evaluate `(value.encode('utf-8'), 'text/plain')`. +- **Line 161:** `else:` — Enter the alternative path only when the preceding condition did not match. +- **Line 162:** `payload = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8")` — Set `payload` to the complete serialized result as immutable bytes: convert text into the selected byte encoding, so storage and limits operate on bytes. +- **Line 163:** `mime = "application/json"` — Set `mime`: use the literal `'application/json'`. +- **Line 164:** `if len(payload) <= self.threshold_bytes:` — Check evaluate `len(payload) <= self.threshold_bytes`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 165:** `return result` — Return from this operation: evaluate `result`. No following statements in this call execute. +- **Line 166:** `owner = caller_owner(context)` — Set `owner` to the caller fingerprint used for access checks: resolve the trusted caller fingerprint, rejecting missing or failed identity. +- **Line 167:** `key = await self.store.put(payload, owner=owner, mime_type=mime)` — Set `key`: await save the full payload with its owner and MIME type and obtain an opaque ID. +- **Line 168:** `return SpilloverResult(generate_preview(value), self.resource_uri_prefix + key, len(payload))` — Return from this operation: call `SpilloverResult` with the arguments shown to perform the current operation. No following statements in this call execute. + +## `nitrostack/core/interceptors/spillover.py` + +Explains 218 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 1:** `"""Bounded, caller-scoped storage for serialized tool results."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 2:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 3:** `from __future__ import annotations` — Import the named APIs from `__future__` so this file can use the shared SDK, library, or test implementation. +- **Line 4:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 5:** `import asyncio` — Import asyncio for the operations described below; importing binds the module names without calling their functions. +- **Line 6:** `import base64` — Import base64 for the operations described below; importing binds the module names without calling their functions. +- **Line 7:** `import json` — Import json for the operations described below; importing binds the module names without calling their functions. +- **Line 8:** `import math` — Import math for the operations described below; importing binds the module names without calling their functions. +- **Line 9:** `import os` — Import os for the operations described below; importing binds the module names without calling their functions. +- **Line 10:** `import re` — Import re for the operations described below; importing binds the module names without calling their functions. +- **Line 11:** `import threading` — Import threading for the operations described below; importing binds the module names without calling their functions. +- **Line 12:** `import time` — Import time for the operations described below; importing binds the module names without calling their functions. +- **Line 13:** `import uuid` — Import uuid for the operations described below; importing binds the module names without calling their functions. +- **Line 14:** `from collections import OrderedDict` — Import the named APIs from `collections` so this file can use the shared SDK, library, or test implementation. +- **Line 15:** `from dataclasses import dataclass` — Import the named APIs from `dataclasses` so this file can use the shared SDK, library, or test implementation. +- **Line 16:** `from pathlib import Path` — Import the named APIs from `pathlib` so this file can use the shared SDK, library, or test implementation. +- **Line 17:** `from typing import Protocol` — Import the named APIs from `typing` so this file can use the shared SDK, library, or test implementation. +- **Line 18:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 19:** `from nitrostack.core.errors import PayloadTooLargeError` — Import the named APIs from `nitrostack.core.errors` so this file can use the shared SDK, library, or test implementation. +- **Line 20:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 21:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverEntry` + +An immutable record holding the exact stored bytes, an opaque owner fingerprint, the media type, and an absolute expiry timestamp. Freezing the record prevents callers from accidentally changing ownership or expiry after retrieval. + +- **Line 22:** `@dataclass(frozen=True)` — Generate an immutable data container: field initialization and comparisons are provided, while frozen=True prevents field reassignment. +- **Line 23:** `class SpilloverEntry:` — Define this class. An immutable record holding the exact stored bytes, an opaque owner fingerprint, the media type, and an absolute expiry timestamp. Freezing the record prevents callers from accidentally changing ownership or expiry after retrieval. +- **Line 24:** `payload: bytes` — Declare `payload` as `bytes` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 25:** `owner: str` — Declare `owner` as `str` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 26:** `mime_type: str` — Declare `mime_type` as `str` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 27:** `expires_at: float` — Declare `expires_at` as `float` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 28:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 29:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverStore` + +This Python Protocol is the contract a custom storage backend must implement. Its async methods let the interceptor use RAM, disk, or a future backend through the same API; the protocol itself stores no data. + +- **Line 30:** `class SpilloverStore(Protocol):` — Define this class. This Python Protocol is the contract a custom storage backend must implement. Its async methods let the interceptor use RAM, disk, or a future backend through the same API; the protocol itself stores no data. +- **Line 31:** `max_bytes: int` — Declare `max_bytes` as `int` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 32:** `ttl_seconds: float` — Declare `ttl_seconds` as `float` in the class contract. This is a type annotation, not an assignment of None; dataclass initialization or the concrete store supplies the runtime value. +- **Line 33:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverStore.put` + +Specify the async save contract without implementing it: bytes plus keyword-only owner/MIME type and optional TTL produce an opaque string ID. The ellipsis is a protocol placeholder, not a storage operation. + +- **Line 34:** `async def put(` — Define an asynchronous operation that callers await. Specify the async save contract without implementing it: bytes plus keyword-only owner/MIME type and optional TTL produce an opaque string ID. The ellipsis is a protocol placeholder, not a storage operation. +- **Line 35:** `self, payload: bytes, *, owner: str, mime_type: str,` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 36:** `ttl_seconds: float | None = None,` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 37:** `) -> str: ...` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 38:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverStore.get` + +Specify the async retrieval contract: a key and required owner produce an authorized, unexpired SpilloverEntry, or None. Implementations must enforce ownership themselves. + +- **Line 39:** `async def get(self, key: str, *, owner: str) -> SpilloverEntry | None: ...` — Define an asynchronous operation that callers await. Specify the async retrieval contract: a key and required owner produce an authorized, unexpired SpilloverEntry, or None. Implementations must enforce ownership themselves. +- **Line 40:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverStore.delete` + +Specify the async deletion contract: delete an owner-matching entry and report success as a boolean. An incorrect owner cannot remove another caller's data. + +- **Line 41:** `async def delete(self, key: str, *, owner: str) -> bool: ...` — Define an asynchronous operation that callers await. Specify the async deletion contract: delete an owner-matching entry and report success as a boolean. An incorrect owner cannot remove another caller's data. +- **Line 42:** `(blank)` — Blank separator for readability; it performs no operation. +### `SpilloverStore.cleanup_expired` + +Specify the async cleanup contract: remove expired entries and return how many were removed. A backend chooses the persistence-specific cleanup mechanism. + +- **Line 43:** `async def cleanup_expired(self) -> int: ...` — Define an asynchronous operation that callers await. Specify the async cleanup contract: remove expired entries and return how many were removed. A backend chooses the persistence-specific cleanup mechanism. +- **Line 44:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 45:** `(blank)` — Blank separator for readability; it performs no operation. +### `_valid_key` + +IDs must contain exactly 32 lowercase hexadecimal characters. This intentionally excludes slashes, backslashes, drive letters, percent escapes, query strings, and parent-directory markers before any filesystem path is built. + +- **Line 46:** `def _valid_key(key: str) -> bool:` — Define this callable. IDs must contain exactly 32 lowercase hexadecimal characters. This intentionally excludes slashes, backslashes, drive letters, percent escapes, query strings, and parent-directory markers before any filesystem path is built. +- **Line 47:** `return isinstance(key, str) and re.fullmatch(r"[0-9a-f]{32}", key) is not None` — Return from this operation: evaluate `isinstance(key, str) and re.fullmatch('[0-9a-f]{32}', key) is not None`. No following statements in this call execute. +- **Line 48:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 49:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore` + +The RAM implementation uses an OrderedDict as an LRU queue and a byte counter for capacity. A reentrant thread lock protects updates to the queue and counter as one operation; reads update recency but never extend TTL. + +- **Line 50:** `class MemorySpilloverStore:` — Define this class. The RAM implementation uses an OrderedDict as an LRU queue and a byte counter for capacity. A reentrant thread lock protects updates to the queue and counter as one operation; reads update recency but never extend TTL. +- **Line 51:** `"""Thread-safe payload cache with absolute TTL and LRU eviction."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 52:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore.__init__` + +Validate positive integer storage caps and finite positive TTL, then initialize the empty LRU queue, a reentrant lock, and the byte counter. + +- **Line 53:** `def __init__(` — Define this callable. Validate positive integer storage caps and finite positive TTL, then initialize the empty LRU queue, a reentrant lock, and the byte counter. +- **Line 54:** `self, *, max_bytes: int = 10 * 1024 * 1024,` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 55:** `max_total_bytes: int = 100 * 1024 * 1024, ttl_seconds: float = 300,` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 56:** `):` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 57:** `if any(type(limit) is not int or limit <= 0 for limit in (max_bytes, max_total_bytes)):` — Check call `any` with the arguments shown to perform the current operation. The indented body handles this case; other results continue past it. +- **Line 58:** `raise ValueError("Storage limits must be positive integers")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 59:** `if not math.isfinite(ttl_seconds) or ttl_seconds <= 0:` — Check evaluate `not math.isfinite(ttl_seconds) or ttl_seconds <= 0`. The indented body handles this case; other results continue past it. +- **Line 60:** `raise ValueError("TTL must be positive and finite")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 61:** `self.max_bytes = max_bytes` — Set `self.max_bytes` to the maximum allowed size of one decoded payload: evaluate `max_bytes`. +- **Line 62:** `self.max_total_bytes = max_total_bytes` — Set `self.max_total_bytes` to the total storage budget: evaluate `max_total_bytes`. +- **Line 63:** `self.ttl_seconds = ttl_seconds` — Set `self.ttl_seconds` to the default lifetime in seconds: evaluate `ttl_seconds`. +- **Line 64:** `self._entries: OrderedDict[str, SpilloverEntry] = OrderedDict()` — Set `self._entries` to the ordered cache whose first key is the least recently used: call `OrderedDict` with the arguments shown to perform the current operation. +- **Line 65:** `self._lock = threading.RLock()` — Set `self._lock` to the lock protecting each store operation from concurrent mutation: call `threading.RLock` with the arguments shown to perform the current operation. +- **Line 66:** `self._size = 0` — Set `self._size` to the sum of live in-memory payload byte lengths: use the literal `0`. +- **Line 67:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore._entry` + +Validate the bytes, ownership, individual size, and TTL before constructing a record. The TTL is converted to an absolute Unix timestamp so the same expiry survives reopening a file store. + +- **Line 68:** `def _entry(self, payload, owner, mime_type, ttl_seconds):` — Define this callable. Validate the bytes, ownership, individual size, and TTL before constructing a record. The TTL is converted to an absolute Unix timestamp so the same expiry survives reopening a file store. +- **Line 69:** `if not isinstance(payload, bytes):` — Check evaluate `not isinstance(payload, bytes)`. The indented body handles this case; other results continue past it. +- **Line 70:** `raise TypeError("Store payload must be bytes")` — Stop this operation and reject values that violate the bytes-only storage contract. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 71:** `if not owner:` — Check evaluate `not owner`. The indented body handles this case; other results continue past it. +- **Line 72:** `raise ValueError("A caller owner is required")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 73:** `if len(payload) > self.max_bytes:` — Check evaluate `len(payload) > self.max_bytes`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 74:** `raise PayloadTooLargeError(f"Payload exceeds max_bytes={self.max_bytes}")` — Stop this operation and report that this individual serialized payload exceeds the configured store maximum. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 75:** `ttl = self.ttl_seconds if ttl_seconds is None else ttl_seconds` — Set `ttl`: select `self.ttl_seconds` when `ttl_seconds is None` is true; otherwise select `ttl_seconds`. +- **Line 76:** `if not math.isfinite(ttl) or ttl <= 0:` — Check evaluate `not math.isfinite(ttl) or ttl <= 0`. The indented body handles this case; other results continue past it. +- **Line 77:** `raise ValueError("TTL must be positive and finite")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 78:** `return SpilloverEntry(payload, owner, mime_type, time.time() + ttl)` — Return from this operation: call `SpilloverEntry` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 79:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore._remove` + +Remove an in-memory entry and subtract exactly its payload length from the capacity counter. Keeping both changes together prevents stale accounting after eviction or deletion. + +- **Line 80:** `def _remove(self, key):` — Define this callable. Remove an in-memory entry and subtract exactly its payload length from the capacity counter. Keeping both changes together prevents stale accounting after eviction or deletion. +- **Line 81:** `entry = self._entries.pop(key)` — Set `entry`: remove the named mapping entry and return it when needed. +- **Line 82:** `self._size -= len(entry.payload)` — Update `self._size` using `Sub` and `len(entry.payload)`; this adjusts the running counter or controlled test clock. +- **Line 83:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore._cleanup` + +Find entries whose absolute deadlines have passed and remove them using the same accounting helper. The returned number supports cleanup assertions and callers that want to observe expiry work. + +- **Line 84:** `def _cleanup(self):` — Define this callable. Find entries whose absolute deadlines have passed and remove them using the same accounting helper. The returned number supports cleanup assertions and callers that want to observe expiry work. +- **Line 85:** `expired = [key for key, entry in self._entries.items() if entry.expires_at <= time.time()]` — Set `expired`: evaluate `[key for key, entry in self._entries.items() if entry.expires_at <= time.time()]`. +- **Line 86:** `for key in expired:` — Iterate `key` through `expired`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 87:** `self._remove(key)` — Delete the cache entry and subtract its payload bytes from the running capacity counter. +- **Line 88:** `return len(expired)` — Return from this operation: measure the value; for a bytes object this is bytes, while string/list/dictionary lengths are characters/items/entries. No following statements in this call execute. +- **Line 89:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore.put` + +Create a validated record, remove expired records, evict least-recently-used entries until it fits, assign a new UUID, and update accounting under one lock. + +- **Line 90:** `async def put(self, payload, *, owner, mime_type, ttl_seconds=None):` — Define an asynchronous operation that callers await. Create a validated record, remove expired records, evict least-recently-used entries until it fits, assign a new UUID, and update accounting under one lock. +- **Line 91:** `entry = self._entry(payload, owner, mime_type, ttl_seconds)` — Set `entry`: validate the bytes, owner, payload maximum, and TTL, then create an immutable record with an absolute expiry deadline. +- **Line 92:** `if len(payload) > self.max_total_bytes:` — Check evaluate `len(payload) > self.max_total_bytes`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 93:** `raise ValueError("Payload exceeds total store capacity")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 94:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 95:** `self._cleanup()` — Remove expired ram entries and repair the payload-size counter before subsequent cache work. +- **Line 96:** `while self._size + len(payload) > self.max_total_bytes:` — Repeat the indented operation while `self._size + len(payload) > self.max_total_bytes` remains true; each iteration reduces the preview or frees capacity so the loop can finish. +- **Line 97:** `self._remove(next(iter(self._entries)))` — Delete the cache entry and subtract its payload bytes from the running capacity counter. +- **Line 98:** `key = uuid.uuid4().hex` — Set `key`: evaluate `uuid.uuid4().hex`. +- **Line 99:** `self._entries[key] = entry` — Set `self._entries[key]`: evaluate `entry`. +- **Line 100:** `self._size += len(payload)` — Update `self._size` using `Add` and `len(payload)`; this adjusts the running counter or controlled test clock. +- **Line 101:** `return key` — Return from this operation: evaluate `key`. No following statements in this call execute. +- **Line 102:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore.get` + +Expire old entries, return None for invalid IDs or wrong owners, and move an authorized hit to the most-recently-used end. + +- **Line 103:** `async def get(self, key, *, owner):` — Define an asynchronous operation that callers await. Expire old entries, return None for invalid IDs or wrong owners, and move an authorized hit to the most-recently-used end. +- **Line 104:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 105:** `self._cleanup()` — Remove expired ram entries and repair the payload-size counter before subsequent cache work. +- **Line 106:** `entry = self._entries.get(key) if _valid_key(key) else None` — Set `entry`: select `self._entries.get(key)` when `_valid_key(key)` is true; otherwise select `None`. +- **Line 107:** `if entry is None or entry.owner != owner:` — Check evaluate `entry is None or entry.owner != owner`. The indented body handles this case; other results continue past it. +- **Line 108:** `return None` — Return from this operation: use the literal `None`. No following statements in this call execute. +- **Line 109:** `self._entries.move_to_end(key)` — Mark this cache key as most recently used for the next lru eviction. +- **Line 110:** `return entry` — Return from this operation: evaluate `entry`. No following statements in this call execute. +- **Line 111:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore.delete` + +Only an owner-matching entry may be removed. Missing or wrong-owner entries return False; successful removal updates the byte counter. + +- **Line 112:** `async def delete(self, key, *, owner):` — Define an asynchronous operation that callers await. Only an owner-matching entry may be removed. Missing or wrong-owner entries return False; successful removal updates the byte counter. +- **Line 113:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 114:** `entry = self._entries.get(key) if _valid_key(key) else None` — Set `entry`: select `self._entries.get(key)` when `_valid_key(key)` is true; otherwise select `None`. +- **Line 115:** `if entry is None or entry.owner != owner:` — Check evaluate `entry is None or entry.owner != owner`. The indented body handles this case; other results continue past it. +- **Line 116:** `return False` — Return from this operation: use the literal `False`. No following statements in this call execute. +- **Line 117:** `self._remove(key)` — Delete the cache entry and subtract its payload bytes from the running capacity counter. +- **Line 118:** `return True` — Return from this operation: use the literal `True`. No following statements in this call execute. +- **Line 119:** `(blank)` — Blank separator for readability; it performs no operation. +### `MemorySpilloverStore.cleanup_expired` + +Expose expiry cleanup as an async contract method while keeping the short RAM mutation inside the thread lock. + +- **Line 120:** `async def cleanup_expired(self):` — Define an asynchronous operation that callers await. Expose expiry cleanup as an async contract method while keeping the short RAM mutation inside the thread lock. +- **Line 121:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 122:** `return self._cleanup()` — Return from this operation: remove expired RAM entries and repair the payload-size counter before subsequent cache work. No following statements in this call execute. +- **Line 123:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 124:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore` + +The disk implementation reuses configuration and payload validation from the memory implementation, but overrides persistence operations. Each JSON file contains a base64 payload and ownership/expiry metadata; the disk capacity counts the entire file, not just decoded bytes. Worker threads keep disk I/O off the event loop. One instance/process must own each root. + +- **Line 125:** `class FileSpilloverStore(MemorySpilloverStore):` — Define this class. The disk implementation reuses configuration and payload validation from the memory implementation, but overrides persistence operations. Each JSON file contains a base64 payload and ownership/expiry metadata; the disk capacity counts the entire file, not just decoded bytes. Worker threads keep disk I/O off the event loop. One instance/process must own each root. +- **Line 126:** `"""Atomic disk store. Each root must be owned by one store instance/process.` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 127:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 128:** `Total capacity counts actual JSON file bytes, including base64 and metadata.` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 129:** `File operations run in worker threads; no background cleanup task is needed.` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 130:** `"""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 131:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore.__init__` + +Initialize shared limits, resolve and create a private storage directory, then remove expired records left from previous runs. + +- **Line 132:** `def __init__(self, root: str | Path, **kwargs):` — Define this callable. Initialize shared limits, resolve and create a private storage directory, then remove expired records left from previous runs. +- **Line 133:** `super().__init__(**kwargs)` — Initialize the inherited validation and limits, preserving the same storage contract for the disk implementation or configured interceptor subclass. +- **Line 134:** `self.root = Path(root).resolve()` — Set `self.root` to the resolved filesystem storage boundary: resolve the filesystem path or dependency token against its owning API. +- **Line 135:** `self.root.mkdir(mode=0o700, parents=True, exist_ok=True)` — Create the storage directory and necessary parents. +- **Line 136:** `self._cleanup_files()` — Sweep expired uuid-named disk records under the storage root so their space can be reclaimed. +- **Line 137:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore._path` + +Convert a validated opaque ID into one JSON filename under the configured root. Reject symlinks and resolved paths outside that root before reading, deleting, or accounting for a file. + +- **Line 138:** `def _path(self, key):` — Define this callable. Convert a validated opaque ID into one JSON filename under the configured root. Reject symlinks and resolved paths outside that root before reading, deleting, or accounting for a file. +- **Line 139:** `if not _valid_key(key):` — Check evaluate `not _valid_key(key)`. The indented body handles this case; other results continue past it. +- **Line 140:** `raise ValueError("Invalid spillover ID")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 141:** `path = self.root / f"{key}.json"` — Set `path`: evaluate `self.root / f'{key}.json'`. +- **Line 142:** `if path.is_symlink() or path.resolve().parent != self.root:` — Check evaluate `path.is_symlink() or path.resolve().parent != self.root`. The indented body handles this case; other results continue past it. +- **Line 143:** `raise ValueError("Spillover path escapes storage root")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 144:** `return path` — Return from this operation: evaluate `path`. No following statements in this call execute. +- **Line 145:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore._read` + +Reconstruct an immutable entry from a stored JSON document. Base64 validation rejects malformed encoded data rather than silently ignoring invalid characters. + +- **Line 146:** `def _read(self, path):` — Define this callable. Reconstruct an immutable entry from a stored JSON document. Base64 validation rejects malformed encoded data rather than silently ignoring invalid characters. +- **Line 147:** `raw = json.loads(path.read_text(encoding="utf-8"))` — Set `raw`: parse stored JSON text back into Python data. +- **Line 148:** `return SpilloverEntry(` — Return from this operation: call `SpilloverEntry` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 149:** `base64.b64decode(raw["payload"], validate=True), raw["owner"],` — Return from this operation: call `SpilloverEntry` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 150:** `raw["mime_type"], raw["expires_at"],` — Return from this operation: call `SpilloverEntry` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 151:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 152:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore._cleanup_files` + +Visit only UUID-shaped JSON filenames owned by this store, inspect expiry, and unlink expired files. Other filenames are not treated as spillover entries; invalid owned files fail rather than bypassing safety checks. + +- **Line 153:** `def _cleanup_files(self):` — Define this callable. Visit only UUID-shaped JSON filenames owned by this store, inspect expiry, and unlink expired files. Other filenames are not treated as spillover entries; invalid owned files fail rather than bypassing safety checks. +- **Line 154:** `count = 0` — Set `count`: use the literal `0`. +- **Line 155:** `for candidate in self.root.glob("*.json"):` — Iterate `candidate` through `self.root.glob('*.json')`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 156:** `if not _valid_key(candidate.stem):` — Check evaluate `not _valid_key(candidate.stem)`. The indented body handles this case; other results continue past it. +- **Line 157:** `continue` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 158:** `path = self._path(candidate.stem)` — Set `path`: validate the opaque ID and resolve its filename against the store root while rejecting symlinks and escaped paths. +- **Line 159:** `if self._read(path).expires_at <= time.time():` — Check evaluate `self._read(path).expires_at <= time.time()`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 160:** `path.unlink()` — Remove this store-owned file, without deleting the target of any rejected symlink. +- **Line 161:** `count += 1` — Update `count` using `Add` and `1`; this adjusts the running counter or controlled test clock. +- **Line 162:** `return count` — Return from this operation: evaluate `count`. No following statements in this call execute. +- **Line 163:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore._put_file` + +Validate and serialize a disk entry, clean expired entries, and check actual disk usage while holding the store lock. Write an exclusively created temporary file and atomically replace it with the final filename so readers do not see partial JSON. + +- **Line 164:** `def _put_file(self, payload, owner, mime_type, ttl_seconds):` — Define this callable. Validate and serialize a disk entry, clean expired entries, and check actual disk usage while holding the store lock. Write an exclusively created temporary file and atomically replace it with the final filename so readers do not see partial JSON. +- **Line 165:** `entry = self._entry(payload, owner, mime_type, ttl_seconds)` — Set `entry`: validate the bytes, owner, payload maximum, and TTL, then create an immutable record with an absolute expiry deadline. +- **Line 166:** `encoded = json.dumps({` — Set `encoded`: convert text into the selected byte encoding, so storage and limits operate on bytes. +- **Line 167:** `"payload": base64.b64encode(payload).decode("ascii"),` — Continue building `encoded` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 168:** `"owner": entry.owner, "mime_type": entry.mime_type,` — Continue building `encoded` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 169:** `"expires_at": entry.expires_at,` — Continue building `encoded` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 170:** `}).encode("utf-8")` — Continue building `encoded` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 171:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 172:** `self._cleanup_files()` — Sweep expired uuid-named disk records under the storage root so their space can be reclaimed. +- **Line 173:** `used = sum(self._path(p.stem).stat().st_size for p in self.root.glob("*.json")` — Set `used`: call `sum` with the arguments shown to perform the current operation. +- **Line 174:** `if _valid_key(p.stem))` — Continue building `used` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 175:** `if used + len(encoded) > self.max_total_bytes:` — Check evaluate `used + len(encoded) > self.max_total_bytes`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 176:** `raise ValueError("File store capacity exceeded")` — Stop this operation and reject an invalid configuration, unsafe ID/path, or capacity-exceeding operation. The surrounding SDK or test catches this error; from None suppresses sensitive internal exception details when present. +- **Line 177:** `key = uuid.uuid4().hex` — Set `key`: evaluate `uuid.uuid4().hex`. +- **Line 178:** `target = self._path(key)` — Set `target`: validate the opaque ID and resolve its filename against the store root while rejecting symlinks and escaped paths. +- **Line 179:** `temporary = self.root / f"{key}.tmp"` — Set `temporary`: evaluate `self.root / f'{key}.tmp'`. +- **Line 180:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 181:** `descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)` — Set `descriptor`: exclusively create the temporary file with owner-only POSIX permissions, refusing to follow an existing temporary filename. +- **Line 182:** `with os.fdopen(descriptor, "wb") as stream:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 183:** `stream.write(encoded)` — Write the already serialized document to the exclusive temporary stream. +- **Line 184:** `temporary.replace(target)` — Atomically publish the completed temporary file at its final path. +- **Line 185:** `finally:` — Always run cleanup, whether the preceding work succeeded, failed, or was cancelled. +- **Line 186:** `temporary.unlink(missing_ok=True)` — Remove this store-owned file, without deleting the target of any rejected symlink. +- **Line 187:** `return key` — Return from this operation: evaluate `key`. No following statements in this call execute. +- **Line 188:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore._get_file` + +Validate the requested path, clean expired entries, and read the requested record under the same lock. Missing, expired, and wrong-owner records return None; an authorized delete removes the file before returning its record. + +- **Line 189:** `def _get_file(self, key, owner, delete=False):` — Define this callable. Validate the requested path, clean expired entries, and read the requested record under the same lock. Missing, expired, and wrong-owner records return None; an authorized delete removes the file before returning its record. +- **Line 190:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 191:** `path = self._path(key)` — Set `path`: validate the opaque ID and resolve its filename against the store root while rejecting symlinks and escaped paths. +- **Line 192:** `self._cleanup_files()` — Sweep expired uuid-named disk records under the storage root so their space can be reclaimed. +- **Line 193:** `if not path.exists():` — Check evaluate `not path.exists()`. The indented body handles this case; other results continue past it. +- **Line 194:** `return None` — Return from this operation: use the literal `None`. No following statements in this call execute. +- **Line 195:** `entry = self._read(path)` — Set `entry`: decode the validated file's JSON document and reversible base64 payload into a SpilloverEntry. +- **Line 196:** `if entry.expires_at <= time.time():` — Check evaluate `entry.expires_at <= time.time()`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 197:** `path.unlink()` — Remove this store-owned file, without deleting the target of any rejected symlink. +- **Line 198:** `return None` — Return from this operation: use the literal `None`. No following statements in this call execute. +- **Line 199:** `if entry.owner != owner:` — Check evaluate `entry.owner != owner`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 200:** `return None` — Return from this operation: use the literal `None`. No following statements in this call execute. +- **Line 201:** `if delete:` — Check evaluate `delete`. The indented body handles this case; other results continue past it. +- **Line 202:** `path.unlink()` — Remove this store-owned file, without deleting the target of any rejected symlink. +- **Line 203:** `return entry` — Return from this operation: evaluate `entry`. No following statements in this call execute. +- **Line 204:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore.put` + +Pass the blocking atomic-write operation to asyncio.to_thread and await its generated result ID. + +- **Line 205:** `async def put(self, payload, *, owner, mime_type, ttl_seconds=None):` — Define an asynchronous operation that callers await. Pass the blocking atomic-write operation to asyncio.to_thread and await its generated result ID. +- **Line 206:** `return await asyncio.to_thread(self._put_file, payload, owner, mime_type, ttl_seconds)` — Return from this operation: await run blocking filesystem work on a worker thread and await its result without blocking the event loop. No following statements in this call execute. +- **Line 207:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore.get` + +Run the blocking authorized read in a worker thread and return its entry or None. + +- **Line 208:** `async def get(self, key, *, owner):` — Define an asynchronous operation that callers await. Run the blocking authorized read in a worker thread and return its entry or None. +- **Line 209:** `return await asyncio.to_thread(self._get_file, key, owner)` — Return from this operation: await run blocking filesystem work on a worker thread and await its result without blocking the event loop. No following statements in this call execute. +- **Line 210:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore.delete` + +Run the authorized read-and-delete operation in a worker thread and translate a returned entry into a success boolean. + +- **Line 211:** `async def delete(self, key, *, owner):` — Define an asynchronous operation that callers await. Run the authorized read-and-delete operation in a worker thread and translate a returned entry into a success boolean. +- **Line 212:** `return await asyncio.to_thread(self._get_file, key, owner, True) is not None` — Return from this operation: evaluate `await asyncio.to_thread(self._get_file, key, owner, True) is not None`; the branch runs only when this comparison is true. No following statements in this call execute. +- **Line 213:** `(blank)` — Blank separator for readability; it performs no operation. +### `FileSpilloverStore.cleanup_expired` + +Run the locked directory sweep in a worker thread so expiry cleanup does not block asynchronous request handling. + +- **Line 214:** `async def cleanup_expired(self):` — Define an asynchronous operation that callers await. Run the locked directory sweep in a worker thread so expiry cleanup does not block asynchronous request handling. +### `FileSpilloverStore.cleanup_expired.cleanup` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 215:** `def cleanup():` — Define this callable. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 216:** `with self._lock:` — Enter the shown context manager. Locks are released and file streams are closed even if the protected operation raises an exception. +- **Line 217:** `return self._cleanup_files()` — Return from this operation: sweep expired UUID-named disk records under the storage root so their space can be reclaimed. No following statements in this call execute. +### `FileSpilloverStore.cleanup_expired` + +Run the locked directory sweep in a worker thread so expiry cleanup does not block asynchronous request handling. + +- **Line 218:** `return await asyncio.to_thread(cleanup)` — Return from this operation: await run blocking filesystem work on a worker thread and await its result without blocking the event loop. No following statements in this call execute. + +## `nitrostack/core/pipeline.py` + +Explains 6 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `use_interceptors` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 43:** `def use_interceptors(*interceptors: Type[Interceptor] | Interceptor):` — Define this callable. Its body below carries out the surrounding operation; this line defines it without running it. +### `run_pipeline` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 207:** `interceptors: List[Type[Interceptor] | Interceptor],` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +### `run_pipeline.execute_handler_flow` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 251:** `interceptor = (` — Set `interceptor`: select `container.resolve(interceptor_cls)` when `isinstance(interceptor_cls, type)` is true; otherwise select `interceptor_cls`. +- **Line 252:** `container.resolve(interceptor_cls)` — Continue building `interceptor` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 253:** `if isinstance(interceptor_cls, type) else interceptor_cls` — Continue building `interceptor` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 254:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. + +## `tests/test_spillover.py` + +Explains 388 added or replacement lines. Open the source beside this document to follow the exact line numbers. + +### `Module setup and shared constants` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 1:** `"""Issue #67: bounded results, caller isolation, and safe spillover stores."""` — Document the module, class, or operation in a docstring; this explains its contract without changing control flow. +- **Line 2:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 3:** `import asyncio` — Import asyncio for the operations described below; importing binds the module names without calling their functions. +- **Line 4:** `import gc` — Import gc for the operations described below; importing binds the module names without calling their functions. +- **Line 5:** `import json` — Import json for the operations described below; importing binds the module names without calling their functions. +- **Line 6:** `from types import SimpleNamespace` — Import the named APIs from `types` so this file can use the shared SDK, library, or test implementation. +- **Line 7:** `from unittest.mock import MagicMock` — Import the named APIs from `unittest.mock` so this file can use the shared SDK, library, or test implementation. +- **Line 8:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 9:** `import mcp.types as types` — Import mcp.types for the operations described below; importing binds the module names without calling their functions. +- **Line 10:** `import pytest` — Import pytest for the operations described below; importing binds the module names without calling their functions. +- **Line 11:** `from pydantic import BaseModel` — Import the named APIs from `pydantic` so this file can use the shared SDK, library, or test implementation. +- **Line 12:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 13:** `from nitrostack import (` — Import the named APIs from `nitrostack` so this file can use the shared SDK, library, or test implementation. +- **Line 14:** `AuthContext, DataSpilloverInterceptor, DIContainer, ExecutionContext,` — Import or re-export `AuthContext, DataSpilloverInterceptor, DIContainer, ExecutionContext` from `nitrostack` as part of the import block above. +- **Line 15:** `FileSpilloverStore, MemorySpilloverStore, PayloadTooLargeError,` — Import or re-export `FileSpilloverStore, MemorySpilloverStore, PayloadTooLargeError` from `nitrostack` as part of the import block above. +- **Line 16:** `ResourceNotFoundError, generate_preview, module, tool, use_interceptors,` — Import or re-export `ResourceNotFoundError, generate_preview, module, tool, use_interceptors` from `nitrostack` as part of the import block above. +- **Line 17:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 18:** `from nitrostack.core.app import McpApplication` — Import the named APIs from `nitrostack.core.app` so this file can use the shared SDK, library, or test implementation. +- **Line 19:** `from nitrostack.core.interceptors.data_spillover import (` — Import the named APIs from `nitrostack.core.interceptors.data_spillover` so this file can use the shared SDK, library, or test implementation. +- **Line 20:** `SpilloverRegistry, SpilloverResult, caller_owner,` — Import or re-export `SpilloverRegistry, SpilloverResult, caller_owner` from `nitrostack.core.interceptors.data_spillover` as part of the import block above. +- **Line 21:** `)` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 22:** `from nitrostack.protocol.mrtr import InputRequiredResult` — Import the named APIs from `nitrostack.protocol.mrtr` so this file can use the shared SDK, library, or test implementation. +- **Line 23:** `from nitrostack.runtime.request_ctx import RequestContext, request_ctx` — Import the named APIs from `nitrostack.runtime.request_ctx` so this file can use the shared SDK, library, or test implementation. +- **Line 24:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 25:** `(blank)` — Blank separator for readability; it performs no operation. +### `reset_container` + +Reset the singleton dependency-injection container before and after each test so provider instances and authentication services cannot leak between cases. + +- **Line 26:** `@pytest.fixture(autouse=True)` — Register reusable pytest setup; autouse fixtures apply automatically, and parameters select both storage backends. +- **Line 27:** `def reset_container():` — Define this callable. Reset the singleton dependency-injection container before and after each test so provider instances and authentication services cannot leak between cases. +- **Line 28:** `DIContainer.reset()` — Restore the previous request context or empty dependency container. +- **Line 29:** `yield` — Evaluate `(yield)`. +- **Line 30:** `DIContainer.reset()` — Restore the previous request context or empty dependency container. +- **Line 31:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 32:** `(blank)` — Blank separator for readability; it performs no operation. +### `store` + +Run storage contract tests against both RAM and disk with the same limits and TTL. A temporary directory isolates filesystem side effects from the repository and from other test cases. + +- **Line 33:** `@pytest.fixture(params=["memory", "file"])` — Register reusable pytest setup; autouse fixtures apply automatically, and parameters select both storage backends. +- **Line 34:** `def store(request, tmp_path):` — Define this callable. Run storage contract tests against both RAM and disk with the same limits and TTL. A temporary directory isolates filesystem side effects from the repository and from other test cases. +- **Line 35:** `cls = MemorySpilloverStore if request.param == "memory" else FileSpilloverStore` — Set `cls`: select `MemorySpilloverStore` when `request.param == 'memory'` is true; otherwise select `FileSpilloverStore`. +- **Line 36:** `args = () if request.param == "memory" else (tmp_path / "results",)` — Set `args`: select `()` when `request.param == 'memory'` is true; otherwise select `(tmp_path / 'results',)`. +- **Line 37:** `return cls(*args, max_bytes=4096, max_total_bytes=16384, ttl_seconds=10)` — Return from this operation: call `cls` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 38:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 39:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_store_roundtrip_owner_delete` + +Prove that a saved payload and MIME type round-trip exactly, another owner cannot read or delete it, the original owner can delete it, and the removed entry is missing. + +- **Line 40:** `def test_store_roundtrip_owner_delete(store):` — Define this callable. Prove that a saved payload and MIME type round-trip exactly, another owner cannot read or delete it, the original owner can delete it, and the removed entry is missing. +### `test_store_roundtrip_owner_delete.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 41:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 42:** `key = await store.put(b'{"answer":42}', owner="alice", mime_type="application/json")` — Set `key`: await save the full payload with its owner and MIME type and obtain an opaque ID. +- **Line 43:** `assert await store.get(key, owner="bob") is None` — Assert `await store.get(key, owner='bob') is None`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 44:** `assert not await store.delete(key, owner="bob")` — Assert `not await store.delete(key, owner='bob')`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 45:** `entry = await store.get(key, owner="alice")` — Set `entry`: await look up the requested entry under the supplied ownership constraint. +- **Line 46:** `assert entry.payload == b'{"answer":42}'` — Assert `entry.payload == b'{"answer":42}'`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 47:** `assert entry.mime_type == "application/json"` — Assert `entry.mime_type == 'application/json'`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 48:** `assert await store.delete(key, owner="alice")` — Assert `await store.delete(key, owner='alice')`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 49:** `assert await store.get(key, owner="alice") is None` — Assert `await store.get(key, owner='alice') is None`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_store_roundtrip_owner_delete` + +Prove that a saved payload and MIME type round-trip exactly, another owner cannot read or delete it, the original owner can delete it, and the removed entry is missing. + +- **Line 50:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 51:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 52:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_store_expiry_and_payload_limit` + +Control the clock without sleeping. Verify the per-payload error, advance precisely to the expiry boundary, clean it up, and prove the expired resource cannot be retrieved. + +- **Line 53:** `def test_store_expiry_and_payload_limit(store, monkeypatch):` — Define this callable. Control the clock without sleeping. Verify the per-payload error, advance precisely to the expiry boundary, clean it up, and prove the expired resource cannot be retrieved. +- **Line 54:** `clock = [1000.0]` — Set `clock`: evaluate `[1000.0]`. +- **Line 55:** `monkeypatch.setattr("nitrostack.core.interceptors.spillover.time.time", lambda: clock[0])` — Replace this dependency for the duration of the pytest fixture. +### `test_store_expiry_and_payload_limit.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 56:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 57:** `key = await store.put(b"hello", owner="alice", mime_type="text/plain")` — Set `key`: await save the full payload with its owner and MIME type and obtain an opaque ID. +- **Line 58:** `with pytest.raises(PayloadTooLargeError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 59:** `await store.put(b"x" * 4097, owner="alice", mime_type="text/plain")` — Await save the full payload with its owner and mime type and obtain an opaque id. +- **Line 60:** `clock[0] += 10` — Update `clock[0]` using `Add` and `10`; this adjusts the running counter or controlled test clock. +- **Line 61:** `assert await store.cleanup_expired() == 1` — Assert `await store.cleanup_expired() == 1`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 62:** `assert await store.get(key, owner="alice") is None` — Assert `await store.get(key, owner='alice') is None`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_store_expiry_and_payload_limit` + +Control the clock without sleeping. Verify the per-payload error, advance precisely to the expiry boundary, clean it up, and prove the expired resource cannot be retrieved. + +- **Line 63:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 64:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 65:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_memory_lru_and_total_capacity` + +Fill an eight-byte cache with two four-byte results, access the first, then add a third. The second must be evicted because it is now least recently used, and accounting must stay at eight bytes. + +- **Line 66:** `def test_memory_lru_and_total_capacity():` — Define this callable. Fill an eight-byte cache with two four-byte results, access the first, then add a third. The second must be evicted because it is now least recently used, and accounting must stay at eight bytes. +- **Line 67:** `store = MemorySpilloverStore(max_bytes=8, max_total_bytes=8)` — Set `store`: call `MemorySpilloverStore` with the arguments shown to perform the current operation. +### `test_memory_lru_and_total_capacity.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 68:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 69:** `first = await store.put(b"1111", owner="a", mime_type="text/plain")` — Set `first`: await save the full payload with its owner and MIME type and obtain an opaque ID. +- **Line 70:** `second = await store.put(b"2222", owner="a", mime_type="text/plain")` — Set `second`: await save the full payload with its owner and MIME type and obtain an opaque ID. +- **Line 71:** `await store.get(first, owner="a")` — Await look up the requested entry under the supplied ownership constraint. +- **Line 72:** `await store.put(b"3333", owner="a", mime_type="text/plain")` — Await save the full payload with its owner and mime type and obtain an opaque id. +- **Line 73:** `assert await store.get(second, owner="a") is None` — Assert `await store.get(second, owner='a') is None`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 74:** `assert await store.get(first, owner="a") is not None` — Assert `await store.get(first, owner='a') is not None`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 75:** `assert store._size == 8` — Assert `store._size == 8`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_memory_lru_and_total_capacity` + +Fill an eight-byte cache with two four-byte results, access the first, then add a third. The second must be evicted because it is now least recently used, and accounting must stay at eight bytes. + +- **Line 76:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 77:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 78:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_file_capacity_restart_cleanup` + +Prove the file capacity includes stored-document overhead, a full store rejects another write, reopening preserves the data, successful writes leave no temporary files, and reopening after TTL removes expired files. + +- **Line 79:** `def test_file_capacity_restart_cleanup(tmp_path, monkeypatch):` — Define this callable. Prove the file capacity includes stored-document overhead, a full store rejects another write, reopening preserves the data, successful writes leave no temporary files, and reopening after TTL removes expired files. +- **Line 80:** `clock = [1000.0]` — Set `clock`: evaluate `[1000.0]`. +- **Line 81:** `monkeypatch.setattr("nitrostack.core.interceptors.spillover.time.time", lambda: clock[0])` — Replace this dependency for the duration of the pytest fixture. +- **Line 82:** `store = FileSpilloverStore(tmp_path, max_total_bytes=300, ttl_seconds=5)` — Set `store`: call `FileSpilloverStore` with the arguments shown to perform the current operation. +### `test_file_capacity_restart_cleanup.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 83:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 84:** `key = await store.put(b"x" * 80, owner="a", mime_type="text/plain")` — Set `key`: await save the full payload with its owner and MIME type and obtain an opaque ID. +- **Line 85:** `with pytest.raises(ValueError, match="capacity"):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 86:** `await store.put(b"x" * 80, owner="a", mime_type="text/plain")` — Await save the full payload with its owner and mime type and obtain an opaque id. +- **Line 87:** `reopened = FileSpilloverStore(tmp_path, max_total_bytes=300)` — Set `reopened`: call `FileSpilloverStore` with the arguments shown to perform the current operation. +- **Line 88:** `assert (await reopened.get(key, owner="a")).payload == b"x" * 80` — Assert `(await reopened.get(key, owner='a')).payload == b'x' * 80`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 89:** `assert list(tmp_path.glob("*.tmp")) == []` — Assert `list(tmp_path.glob('*.tmp')) == []`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 90:** `clock[0] += 5` — Update `clock[0]` using `Add` and `5`; this adjusts the running counter or controlled test clock. +- **Line 91:** `FileSpilloverStore(tmp_path)` — Call `filespilloverstore` with the arguments shown to perform the current operation. +- **Line 92:** `assert list(tmp_path.glob("*.json")) == []` — Assert `list(tmp_path.glob('*.json')) == []`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_file_capacity_restart_cleanup` + +Prove the file capacity includes stored-document overhead, a full store rejects another write, reopening preserves the data, successful writes leave no temporary files, and reopening after TTL removes expired files. + +- **Line 93:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 94:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 95:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_file_rejects_unsafe_ids` + +Exercise traversal, Windows separators, encoded traversal, absolute paths, drive paths, and query characters. Both read and delete must reject them before touching another location. + +- **Line 96:** `@pytest.mark.parametrize("key", ["../escape", "..\\escape", "%2e%2e%2fescape", "/etc/passwd", "C:\\escape", "x?y"])` — Ask pytest to run this test once for each supplied case, so distinct shapes or invalid inputs are checked independently. +- **Line 97:** `def test_file_rejects_unsafe_ids(tmp_path, key):` — Define this callable. Exercise traversal, Windows separators, encoded traversal, absolute paths, drive paths, and query characters. Both read and delete must reject them before touching another location. +- **Line 98:** `store = FileSpilloverStore(tmp_path)` — Set `store`: call `FileSpilloverStore` with the arguments shown to perform the current operation. +### `test_file_rejects_unsafe_ids.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 99:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 100:** `with pytest.raises(ValueError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 101:** `await store.get(key, owner="a")` — Await look up the requested entry under the supplied ownership constraint. +- **Line 102:** `with pytest.raises(ValueError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 103:** `await store.delete(key, owner="a")` — Await remove only the requested owner-matching record. +### `test_file_rejects_unsafe_ids` + +Exercise traversal, Windows separators, encoded traversal, absolute paths, drive paths, and query characters. Both read and delete must reject them before touching another location. + +- **Line 104:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 105:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 106:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_file_rejects_symlink_escape` + +Create an actual link from an owned-looking filename to an external file and prove the store rejects it while preserving the external file. Skip only if the OS does not grant link-creation privileges. + +- **Line 107:** `def test_file_rejects_symlink_escape(tmp_path):` — Define this callable. Create an actual link from an owned-looking filename to an external file and prove the store rejects it while preserving the external file. Skip only if the OS does not grant link-creation privileges. +- **Line 108:** `root = tmp_path / "results"` — Set `root`: evaluate `tmp_path / 'results'`. +- **Line 109:** `store = FileSpilloverStore(root)` — Set `store`: call `FileSpilloverStore` with the arguments shown to perform the current operation. +- **Line 110:** `outside = tmp_path / "private.json"` — Set `outside`: evaluate `tmp_path / 'private.json'`. +- **Line 111:** `outside.write_text("private")` — Call `outside.write_text` with the arguments shown to perform the current operation. +- **Line 112:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 113:** `(root / ("a" * 32 + ".json")).symlink_to(outside)` — Call `(root / ('a' * 32 + '.json')).symlink_to` with the arguments shown to perform the current operation. +- **Line 114:** `except OSError:` — Handle only the exception types shown; the following body translates the failure or applies the documented fallback. +- **Line 115:** `pytest.skip("Creating symlinks requires OS privileges")` — Call `pytest.skip` with the arguments shown to perform the current operation. +- **Line 116:** `with pytest.raises(ValueError, match="escapes"):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 117:** `asyncio.run(store.get("a" * 32, owner="a"))` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 118:** `assert outside.read_text() == "private"` — Assert `outside.read_text() == 'private'`. A false result fails this test immediately, checking the behavior described for `test_file_rejects_symlink_escape` rather than silently accepting a regression. +- **Line 119:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 120:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_concurrent_store_operations` + +Start several writes together and then several reads. IDs must be unique, payload order must round-trip, and lock-protected operations must avoid corruption or accidental replacement. + +- **Line 121:** `def test_concurrent_store_operations(store):` — Define this callable. Start several writes together and then several reads. IDs must be unique, payload order must round-trip, and lock-protected operations must avoid corruption or accidental replacement. +### `test_concurrent_store_operations.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 122:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 123:** `keys = await asyncio.gather(*[` — Set `keys`: await schedule the supplied asynchronous operations together and collect their results in input order. +- **Line 124:** `store.put(str(i).encode(), owner="a", mime_type="text/plain") for i in range(20)` — Continue building `keys` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 125:** `])` — Continue building `keys` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 126:** `assert len(set(keys)) == 20` — Assert `len(set(keys)) == 20`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 127:** `entries = await asyncio.gather(*[store.get(key, owner="a") for key in keys])` — Set `entries`: await schedule the supplied asynchronous operations together and collect their results in input order. +- **Line 128:** `assert [entry.payload for entry in entries] == [str(i).encode() for i in range(20)]` — Assert `[entry.payload for entry in entries] == [str(i).encode() for i in range(20)]`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_concurrent_store_operations` + +Start several writes together and then several reads. IDs must be unique, payload order must round-trip, and lock-protected operations must avoid corruption or accidental replacement. + +- **Line 129:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 130:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 131:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_previews_are_bounded` + +Cover ordinary lists/dictionaries, multibyte strings, and deeply nested large values. Counts must describe the original data, truncation must be marked, and serialized preview bytes must stay within 2048. + +- **Line 132:** `@pytest.mark.parametrize("value", [list(range(20)), {str(i): i for i in range(20)}, "é" * 10000,` — Ask pytest to run this test once for each supplied case, so distinct shapes or invalid inputs are checked independently. +- **Line 133:** `[{"huge": "x" * 10000}], {"huge": ["x" * 10000]}],` — Continue the preceding Python declaration or expression with the literal name or value shown. +- **Line 134:** `ids=["list", "dict", "unicode", "nested-list", "nested-dict"])` — Continue the preceding Python declaration or expression with the literal name or value shown. +- **Line 135:** `def test_previews_are_bounded(value):` — Define this callable. Cover ordinary lists/dictionaries, multibyte strings, and deeply nested large values. Counts must describe the original data, truncation must be marked, and serialized preview bytes must stay within 2048. +- **Line 136:** `preview = generate_preview(value)` — Set `preview`: build the bounded head/count/text description of the original successful data. +- **Line 137:** `assert preview["count"] == len(value)` — Assert `preview['count'] == len(value)`. A false result fails this test immediately, checking the behavior described for `test_previews_are_bounded` rather than silently accepting a regression. +- **Line 138:** `assert preview["truncated"]` — Assert `preview['truncated']`. A false result fails this test immediately, checking the behavior described for `test_previews_are_bounded` rather than silently accepting a regression. +- **Line 139:** `assert len(json.dumps(preview, ensure_ascii=False).encode()) <= 2048` — Assert `len(json.dumps(preview, ensure_ascii=False).encode()) <= 2048`. A false result fails this test immediately, checking the behavior described for `test_previews_are_bounded` rather than silently accepting a regression. +- **Line 140:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 141:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_threshold_boundary_and_full_payload` + +Verify that equal-to-threshold Unicode data returns the exact same object, larger data spills, stored JSON matches the original result, and reported size is bytes rather than characters. + +- **Line 142:** `def test_threshold_boundary_and_full_payload(store):` — Define this callable. Verify that equal-to-threshold Unicode data returns the exact same object, larger data spills, stored JSON matches the original result, and reported size is bytes rather than characters. +- **Line 143:** `interceptor = DataSpilloverInterceptor(threshold_bytes=4, store=store)` — Set `interceptor`: call `DataSpilloverInterceptor` with the arguments shown to perform the current operation. +- **Line 144:** `context = ExecutionContext(request_id="1", auth=AuthContext(subject="alice"), logger=MagicMock())` — Set `context`: construct a fresh per-call context; its request ID identifies the operation while identity is populated separately from trusted request sources. +### `test_threshold_boundary_and_full_payload.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 145:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 146:** `for value in ["éé", {"a": 1}, [1, 2]]:` — Iterate `value` through `['éé', {'a': 1}, [1, 2]]`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +### `test_threshold_boundary_and_full_payload.scenario.handler` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 147:** `async def handler():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 148:** `return value` — Return from this operation: evaluate `value`. No following statements in this call execute. +### `test_threshold_boundary_and_full_payload.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 149:** `result = await interceptor.intercept(context, handler)` — Set `result`: await call `interceptor.intercept` with the arguments shown to perform the current operation. +- **Line 150:** `if value == "éé":` — Check evaluate `value == 'éé'`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 151:** `assert result is value` — Assert `result is value`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 152:** `else:` — Enter the alternative path only when the preceding condition did not match. +- **Line 153:** `assert isinstance(result, SpilloverResult)` — Assert `isinstance(result, SpilloverResult)`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 154:** `entry = await store.get(result.resource_uri.rsplit("/", 1)[1], owner=caller_owner(context))` — Set `entry`: await look up the requested entry under the supplied ownership constraint. +- **Line 155:** `assert json.loads(entry.payload) == value` — Assert `json.loads(entry.payload) == value`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 156:** `assert result.original_size_bytes == len(entry.payload)` — Assert `result.original_size_bytes == len(entry.payload)`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_threshold_boundary_and_full_payload.scenario.unicode_handler` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 157:** `async def unicode_handler():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 158:** `return "ééé"` — Return from this operation: use the literal `'ééé'`. No following statements in this call execute. +### `test_threshold_boundary_and_full_payload.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 159:** `assert (await interceptor.intercept(context, unicode_handler)).original_size_bytes == 6` — Assert `(await interceptor.intercept(context, unicode_handler)).original_size_bytes == 6`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_threshold_boundary_and_full_payload` + +Verify that equal-to-threshold Unicode data returns the exact same object, larger data spills, stored JSON matches the original result, and reported size is bytes rather than characters. + +- **Line 160:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 161:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 162:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_control_results_and_missing_identity` + +Ensure errors and input-required control responses pass through by identity even with a tiny threshold. A large ordinary result without any trusted caller must raise PermissionError. + +- **Line 163:** `def test_control_results_and_missing_identity():` — Define this callable. Ensure errors and input-required control responses pass through by identity even with a tiny threshold. A large ordinary result without any trusted caller must raise PermissionError. +- **Line 164:** `interceptor = DataSpilloverInterceptor(threshold_bytes=1)` — Set `interceptor`: call `DataSpilloverInterceptor` with the arguments shown to perform the current operation. +- **Line 165:** `context = ExecutionContext(request_id="1", logger=MagicMock())` — Set `context`: construct a fresh per-call context; its request ID identifies the operation while identity is populated separately from trusted request sources. +### `test_control_results_and_missing_identity.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 166:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 167:** `controls = [types.CallToolResult(content=[], isError=True),` — Set `controls`: evaluate `[types.CallToolResult(content=[], isError=True), {'resultType': 'input_required', 'message': 'more'}, InputRequiredResult(input_requests=[], request_state={}, message='more')]`. +- **Line 168:** `{"resultType": "input_required", "message": "more"},` — Continue building `controls` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 169:** `InputRequiredResult(input_requests=[], request_state={}, message="more")]` — Continue building `controls` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 170:** `for value in controls:` — Iterate `value` through `controls`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +### `test_control_results_and_missing_identity.scenario.handler` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 171:** `async def handler():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 172:** `return value` — Return from this operation: evaluate `value`. No following statements in this call execute. +### `test_control_results_and_missing_identity.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 173:** `assert await interceptor.intercept(context, handler) is value` — Assert `await interceptor.intercept(context, handler) is value`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_control_results_and_missing_identity.scenario.large` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 174:** `async def large():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 175:** `return "large"` — Return from this operation: use the literal `'large'`. No following statements in this call execute. +### `test_control_results_and_missing_identity.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 176:** `with pytest.raises(PermissionError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 177:** `await interceptor.intercept(context, large)` — Await call `interceptor.intercept` with the arguments shown to perform the current operation. +### `test_control_results_and_missing_identity` + +Ensure errors and input-required control responses pass through by identity even with a tiny threshold. A large ordinary result without any trusted caller must raise PermissionError. + +- **Line 178:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 179:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 180:** `(blank)` — Blank separator for readability; it performs no operation. +### `Session` + +A minimal weak-referenceable session stand-in. Object identity, not a caller-supplied string, is the basis of anonymous ownership in these tests. + +- **Line 181:** `class Session:` — Define this class. A minimal weak-referenceable session stand-in. Object identity, not a caller-supplied string, is the basis of anonymous ownership in these tests. +- **Line 182:** `pass` — Perform no operation in this intentionally empty class or handled-error branch. +- **Line 183:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 184:** `(blank)` — Blank separator for readability; it performs no operation. +### `ToolInput` + +A Pydantic test input model with a default result string. It lets the tests exercise the actual SDK argument validation and tool pipeline. + +- **Line 185:** `class ToolInput(BaseModel):` — Define this class. A Pydantic test input model with a default result string. It lets the tests exercise the actual SDK argument validation and tool pipeline. +- **Line 186:** `text: str = "large result"` — Set `text`: use the literal `'large result'`. +- **Line 187:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 188:** `(blank)` — Blank separator for readability; it performs no operation. +### `make_app` + +Build a small real SDK application around one decorated tool. Parameters let tests choose a configured interceptor instance or class, task support, and an output schema without duplicating application setup. + +- **Line 189:** `def make_app(interceptor, *, task_support="forbidden", output_schema=None):` — Define this callable. Build a small real SDK application around one decorated tool. Parameters let tests choose a configured interceptor instance or class, task support, and an output schema without duplicating application setup. +### `make_app.Tools` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 190:** `class Tools:` — Define this class. Group the methods below into the provider or test configuration shown. +- **Line 191:** `@tool(name="large", description="Test spillover", input_schema=ToolInput,` — Publish this method as an MCP tool with its input validation and optional task/output metadata. +- **Line 192:** `task_support=task_support, output_schema=output_schema)` — Continue the current declaration or expression using the exact syntax shown; its purpose is described in the enclosing operation above. +- **Line 193:** `@use_interceptors(interceptor)` — Attach this interceptor configuration to the tool method; execution is deferred until the tool pipeline runs. +### `make_app.Tools.large` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 194:** `async def large(self, input, context):` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 195:** `return {"text": input.text}` — Return from this operation: evaluate `{'text': input.text}`. No following statements in this call execute. +### `make_app` + +Build a small real SDK application around one decorated tool. Parameters let tests choose a configured interceptor instance or class, task support, and an output schema without duplicating application setup. + +- **Line 196:** `@module(name="spillover-test", providers=[Tools])` — Register the test provider as part of an SDK module so application bootstrap discovers its decorated tool. +### `make_app.Root` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 197:** `class Root:` — Define this class. Group the methods below into the provider or test configuration shown. +- **Line 198:** `pass` — Perform no operation in this intentionally empty class or handled-error branch. +### `make_app` + +Build a small real SDK application around one decorated tool. Parameters let tests choose a configured interceptor instance or class, task support, and an output schema without duplicating application setup. + +- **Line 199:** `return McpApplication(Root)` — Return from this operation: call `McpApplication` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 200:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 201:** `(blank)` — Blank separator for readability; it performs no operation. +### `call` + +Invoke the SDK tool-call handler while installing an explicit request context. Always reset the ContextVar afterward; task tests use the same application path with explicit task metadata. + +- **Line 202:** `async def call(app, rc, text="large result", task=None):` — Define an asynchronous operation that callers await. Invoke the SDK tool-call handler while installing an explicit request context. Always reset the ContextVar afterward; task tests use the same application path with explicit task metadata. +- **Line 203:** `token = request_ctx.set(rc)` — Set `token`: bind the chosen request context locally to the current async execution and obtain a restoration token. +- **Line 204:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 205:** `if task is not None:` — Check evaluate `task is not None`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 206:** `return await app._call_tool("large", {"text": text}, task=task)` — Return from this operation: await call `app._call_tool` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 207:** `handler = app.mcp_server.request_handlers[types.CallToolRequest]` — Set `handler`: evaluate `app.mcp_server.request_handlers[types.CallToolRequest]`. +- **Line 208:** `return (await handler(types.CallToolRequest(` — Return from this operation: evaluate `(await handler(types.CallToolRequest(method='tools/call', params=types.CallToolRequestParams(name='large', arguments={'text': text})))).root`. No following statements in this call execute. +- **Line 209:** `method="tools/call", params=types.CallToolRequestParams(name="large", arguments={"text": text}),` — Return from this operation: evaluate `(await handler(types.CallToolRequest(method='tools/call', params=types.CallToolRequestParams(name='large', arguments={'text': text})))).root`. No following statements in this call execute. +- **Line 210:** `))).root` — Return from this operation: evaluate `(await handler(types.CallToolRequest(method='tools/call', params=types.CallToolRequestParams(name='large', arguments={'text': text})))).root`. No following statements in this call execute. +- **Line 211:** `finally:` — Always run cleanup, whether the preceding work succeeded, failed, or was cancelled. +- **Line 212:** `request_ctx.reset(token)` — Restore the request context that existed before the matching set call, preventing caller identity leakage between requests. +- **Line 213:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 214:** `(blank)` — Blank separator for readability; it performs no operation. +### `read` + +Invoke the registered resource-read handler under a chosen caller context and return the resource text. The finally block restores context even when access is denied. + +- **Line 215:** `async def read(app, rc, uri):` — Define an asynchronous operation that callers await. Invoke the registered resource-read handler under a chosen caller context and return the resource text. The finally block restores context even when access is denied. +- **Line 216:** `token = request_ctx.set(rc)` — Set `token`: bind the chosen request context locally to the current async execution and obtain a restoration token. +- **Line 217:** `try:` — Begin work whose failure must be handled or whose cleanup must always execute. +- **Line 218:** `handler = app.mcp_server.request_handlers[types.ReadResourceRequest]` — Set `handler`: evaluate `app.mcp_server.request_handlers[types.ReadResourceRequest]`. +- **Line 219:** `return (await handler(types.ReadResourceRequest(` — Return from this operation: evaluate `(await handler(types.ReadResourceRequest(method='resources/read', params=types.ReadResourceRequestParams(uri=uri)))).root.contents[0].text`. No following statements in this call execute. +- **Line 220:** `method="resources/read", params=types.ReadResourceRequestParams(uri=uri),` — Return from this operation: evaluate `(await handler(types.ReadResourceRequest(method='resources/read', params=types.ReadResourceRequestParams(uri=uri)))).root.contents[0].text`. No following statements in this call execute. +- **Line 221:** `))).root.contents[0].text` — Return from this operation: evaluate `(await handler(types.ReadResourceRequest(method='resources/read', params=types.ReadResourceRequestParams(uri=uri)))).root.contents[0].text`. No following statements in this call execute. +- **Line 222:** `finally:` — Always run cleanup, whether the preceding work succeeded, failed, or was cancelled. +- **Line 223:** `request_ctx.reset(token)` — Restore the request context that existed before the matching set call, preventing caller identity leakage between requests. +- **Line 224:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 225:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_mcp_resource_roundtrip_and_session_isolation` + +Test the full registered tool-call/resource-read path with both storage backends. Verify resource links and original size, allow the originating session, and deny a second session, missing caller, and traversal URI. + +- **Line 226:** `def test_mcp_resource_roundtrip_and_session_isolation(store):` — Define this callable. Test the full registered tool-call/resource-read path with both storage backends. Verify resource links and original size, allow the originating session, and deny a second session, missing caller, and traversal URI. +- **Line 227:** `app = make_app(DataSpilloverInterceptor(threshold_bytes=1, store=store))` — Set `app`: construct the real one-tool SDK application used for this test scenario. +- **Line 228:** `alice = RequestContext(session=Session())` — Set `alice`: call `RequestContext` with the arguments shown to perform the current operation. +- **Line 229:** `bob = RequestContext(session=Session())` — Set `bob`: call `RequestContext` with the arguments shown to perform the current operation. +### `test_mcp_resource_roundtrip_and_session_isolation.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 230:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 231:** `result = await call(app, alice)` — Set `result`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 232:** `assert not result.is_error` — Assert `not result.is_error`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 233:** `data = result.structured_content` — Set `data`: evaluate `result.structured_content`. +- **Line 234:** `assert result.content[1].type == "resource_link"` — Assert `result.content[1].type == 'resource_link'`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 235:** `assert json.loads(await read(app, alice, data["resource_uri"])) == {"text": "large result"}` — Assert `json.loads(await read(app, alice, data['resource_uri'])) == {'text': 'large result'}`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 236:** `for caller in [bob, None]:` — Iterate `caller` through `[bob, None]`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 237:** `with pytest.raises(ResourceNotFoundError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 238:** `await read(app, caller, data["resource_uri"])` — Await invoke the application's registered mcp resource-read path under the chosen caller context. +- **Line 239:** `with pytest.raises(ResourceNotFoundError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 240:** `await read(app, alice, "resource://data-spillover/../escape")` — Await invoke the application's registered mcp resource-read path under the chosen caller context. +- **Line 241:** `assert data["original_size_bytes"] == len(b'{"text":"large result"}')` — Assert `data['original_size_bytes'] == len(b'{"text":"large result"}')`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_mcp_resource_roundtrip_and_session_isolation` + +Test the full registered tool-call/resource-read path with both storage backends. Verify resource links and original size, allow the originating session, and deny a second session, missing caller, and traversal URI. + +- **Line 242:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 243:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 244:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_principal_tenant_issuer_isolation` + +Stub only token verification so the SDK still exercises its normal identity extraction. Same-principal access works across session objects; different users, tenants, issuers, and invalid tokens cannot read the result. Unsigned identity metadata does not grant access. + +- **Line 245:** `def test_principal_tenant_issuer_isolation(monkeypatch):` — Define this callable. Stub only token verification so the SDK still exercises its normal identity extraction. Same-principal access works across session objects; different users, tenants, issuers, and invalid tokens cannot read the result. Unsigned identity metadata does not grant access. +- **Line 246:** `payloads = {` — Set `payloads`: evaluate `{'alice': {'sub': 'alice', 'iss': 'issuer', 'tenant_id': 'one'}, 'bob': {'sub': 'bob', 'iss': 'issuer', 'tenant_id': 'one'}, 'tenant': {'sub': 'alice', 'iss': 'issuer', 'tenant_id': 'two'}, 'issuer': {'sub': 'alice', 'iss': 'other', 'tenant_id': 'one'}}`. +- **Line 247:** `"alice": {"sub": "alice", "iss": "issuer", "tenant_id": "one"},` — Continue building `payloads` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 248:** `"bob": {"sub": "bob", "iss": "issuer", "tenant_id": "one"},` — Continue building `payloads` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 249:** `"tenant": {"sub": "alice", "iss": "issuer", "tenant_id": "two"},` — Continue building `payloads` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 250:** `"issuer": {"sub": "alice", "iss": "other", "tenant_id": "one"},` — Continue building `payloads` with the field, argument, or value shown. It belongs to the assignment begun above rather than a separate assignment. +- **Line 251:** `}` — Close the continued expression or signature begun above; this delimiter groups its preceding arguments or fields and adds no separate runtime action. +- **Line 252:** `monkeypatch.setattr("nitrostack.auth.request.verify_bearer_payload", payloads.get)` — Replace this dependency for the duration of the pytest fixture. +- **Line 253:** `app = make_app(DataSpilloverInterceptor(threshold_bytes=1))` — Set `app`: construct the real one-tool SDK application used for this test scenario. +### `test_principal_tenant_issuer_isolation.rc` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 254:** `def rc(token):` — Define this callable. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 255:** `return RequestContext(request=SimpleNamespace(headers={"authorization": "Bearer " + token}),` — Return from this operation: call `RequestContext` with the arguments shown to perform the current operation. No following statements in this call execute. +- **Line 256:** `session=Session(), meta={"userId": "alice", "tenantId": "one"})` — Return from this operation: call `RequestContext` with the arguments shown to perform the current operation. No following statements in this call execute. +### `test_principal_tenant_issuer_isolation.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 257:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 258:** `result = await call(app, rc("alice"))` — Set `result`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 259:** `uri = result.structured_content["resource_uri"]` — Set `uri`: evaluate `result.structured_content['resource_uri']`. +- **Line 260:** `assert json.loads(await read(app, rc("alice"), uri)) == {"text": "large result"}` — Assert `json.loads(await read(app, rc('alice'), uri)) == {'text': 'large result'}`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 261:** `for other in ["bob", "tenant", "issuer", "invalid"]:` — Iterate `other` through `['bob', 'tenant', 'issuer', 'invalid']`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 262:** `with pytest.raises(ResourceNotFoundError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 263:** `await read(app, rc(other), uri)` — Await invoke the application's registered mcp resource-read path under the chosen caller context. +- **Line 264:** `assert (await call(app, rc("invalid"))).is_error` — Assert `(await call(app, rc('invalid'))).is_error`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_principal_tenant_issuer_isolation` + +Stub only token verification so the SDK still exercises its normal identity extraction. Same-principal access works across session objects; different users, tenants, issuers, and invalid tokens cannot read the result. Unsigned identity metadata does not grant access. + +- **Line 265:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 266:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 267:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_class_decorator_custom_prefix_and_schema` + +Check backward-compatible class-based interceptor construction, a custom resource prefix, and output-schema advertisement that accepts the original object or a spillover envelope. + +- **Line 268:** `def test_class_decorator_custom_prefix_and_schema():` — Define this callable. Check backward-compatible class-based interceptor construction, a custom resource prefix, and output-schema advertisement that accepts the original object or a spillover envelope. +### `test_class_decorator_custom_prefix_and_schema.Configured` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 269:** `class Configured(DataSpilloverInterceptor):` — Define this class. Group the methods below into the provider or test configuration shown. +### `test_class_decorator_custom_prefix_and_schema.Configured.__init__` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 270:** `def __init__(self):` — Define this callable. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 271:** `super().__init__(threshold_bytes=1, resource_uri_prefix="resource://custom/")` — Initialize the inherited validation and limits, preserving the same storage contract for the disk implementation or configured interceptor subclass. +### `test_class_decorator_custom_prefix_and_schema` + +Check backward-compatible class-based interceptor construction, a custom resource prefix, and output-schema advertisement that accepts the original object or a spillover envelope. + +- **Line 272:** `schema = {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}` — Set `schema`: evaluate `{'type': 'object', 'properties': {'text': {'type': 'string'}}, 'required': ['text']}`. +- **Line 273:** `app = make_app(Configured, output_schema=schema)` — Set `app`: construct the real one-tool SDK application used for this test scenario. +### `test_class_decorator_custom_prefix_and_schema.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 274:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 275:** `rc = RequestContext(session=Session())` — Set `rc`: call `RequestContext` with the arguments shown to perform the current operation. +- **Line 276:** `result = await call(app, rc)` — Set `result`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 277:** `assert result.structured_content["resource_uri"].startswith("resource://custom/")` — Assert `result.structured_content['resource_uri'].startswith('resource://custom/')`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 278:** `definition = app._build_tool_definition(app._tools["large"])` — Set `definition`: call `app._build_tool_definition` with the arguments shown to perform the current operation. +- **Line 279:** `original = definition.output_schema["anyOf"][0]` — Set `original`: evaluate `definition.output_schema['anyOf'][0]`. +- **Line 280:** `assert all(original[key] == value for key, value in schema.items())` — Assert `all((original[key] == value for key, value in schema.items()))`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 281:** `assert definition.output_schema["anyOf"][1]["required"] == ["preview", "resource_uri", "original_size_bytes"]` — Assert `definition.output_schema['anyOf'][1]['required'] == ['preview', 'resource_uri', 'original_size_bytes']`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_class_decorator_custom_prefix_and_schema` + +Check backward-compatible class-based interceptor construction, a custom resource prefix, and output-schema advertisement that accepts the original object or a spillover envelope. + +- **Line 282:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 283:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 284:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_registry_conflicts_and_session_cleanup` + +Allow shared use of one store/prefix, reject a different store claiming it, reuse a live session key, and prove garbage collection removes the registry entry. + +- **Line 285:** `def test_registry_conflicts_and_session_cleanup():` — Define this callable. Allow shared use of one store/prefix, reject a different store claiming it, reuse a live session key, and prove garbage collection removes the registry entry. +- **Line 286:** `registry = SpilloverRegistry()` — Set `registry`: call `SpilloverRegistry` with the arguments shown to perform the current operation. +- **Line 287:** `interceptor = DataSpilloverInterceptor()` — Set `interceptor`: call `DataSpilloverInterceptor` with the arguments shown to perform the current operation. +- **Line 288:** `registry.register(interceptor)` — Call `registry.register` with the arguments shown to perform the current operation. +- **Line 289:** `registry.register(interceptor)` — Call `registry.register` with the arguments shown to perform the current operation. +- **Line 290:** `with pytest.raises(ValueError, match="Conflicting"):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 291:** `registry.register(DataSpilloverInterceptor())` — Call `registry.register` with the arguments shown to perform the current operation. +- **Line 292:** `rc = RequestContext(session=Session())` — Set `rc`: call `RequestContext` with the arguments shown to perform the current operation. +- **Line 293:** `assert registry.session_key(rc) == registry.session_key(rc)` — Assert `registry.session_key(rc) == registry.session_key(rc)`. A false result fails this test immediately, checking the behavior described for `test_registry_conflicts_and_session_cleanup` rather than silently accepting a regression. +- **Line 294:** `del rc` — Delete the local reference so garbage collection can exercise weak-reference cleanup; this does not itself delete a stored file. +- **Line 295:** `gc.collect()` — Call `gc.collect` with the arguments shown to perform the current operation. +- **Line 296:** `assert registry._sessions == {}` — Assert `registry._sessions == {}`. A false result fails this test immediately, checking the behavior described for `test_registry_conflicts_and_session_cleanup` rather than silently accepting a regression. +- **Line 297:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 298:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_mcp_expiry_and_store_errors` + +Check resource expiry at the MCP boundary and verify an oversized result becomes a tool error containing the limit explanation, rather than returning the original large data. + +- **Line 299:** `def test_mcp_expiry_and_store_errors(store, monkeypatch):` — Define this callable. Check resource expiry at the MCP boundary and verify an oversized result becomes a tool error containing the limit explanation, rather than returning the original large data. +- **Line 300:** `clock = [1000.0]` — Set `clock`: evaluate `[1000.0]`. +- **Line 301:** `monkeypatch.setattr("nitrostack.core.interceptors.spillover.time.time", lambda: clock[0])` — Replace this dependency for the duration of the pytest fixture. +- **Line 302:** `app = make_app(DataSpilloverInterceptor(threshold_bytes=1, store=store))` — Set `app`: construct the real one-tool SDK application used for this test scenario. +- **Line 303:** `rc = RequestContext(session=Session())` — Set `rc`: call `RequestContext` with the arguments shown to perform the current operation. +### `test_mcp_expiry_and_store_errors.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 304:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 305:** `result = await call(app, rc)` — Set `result`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 306:** `uri = result.structured_content["resource_uri"]` — Set `uri`: evaluate `result.structured_content['resource_uri']`. +- **Line 307:** `clock[0] += 10` — Update `clock[0]` using `Add` and `10`; this adjusts the running counter or controlled test clock. +- **Line 308:** `with pytest.raises(ResourceNotFoundError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 309:** `await read(app, rc, uri)` — Await invoke the application's registered mcp resource-read path under the chosen caller context. +- **Line 310:** `oversized = await call(app, rc, text="x" * 5000)` — Set `oversized`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 311:** `assert oversized.is_error` — Assert `oversized.is_error`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 312:** `assert "max_bytes" in oversized.content[0].text` — Assert `'max_bytes' in oversized.content[0].text`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 313:** `assert "x" * 5000 not in oversized.content[0].text` — Assert `'x' * 5000 not in oversized.content[0].text`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_mcp_expiry_and_store_errors` + +Check resource expiry at the MCP boundary and verify an oversized result becomes a tool error containing the limit explanation, rather than returning the original large data. + +- **Line 314:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 315:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 316:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_models_mcp_results_and_widget_bypass` + +Check JSON-compatible serialization of Pydantic models and explicit MCP results. Verify the internal spillover wrapper converts to a link without passing through a widget transformer that expects the original data. + +- **Line 317:** `def test_models_mcp_results_and_widget_bypass(store):` — Define this callable. Check JSON-compatible serialization of Pydantic models and explicit MCP results. Verify the internal spillover wrapper converts to a link without passing through a widget transformer that expects the original data. +- **Line 318:** `interceptor = DataSpilloverInterceptor(threshold_bytes=1, store=store)` — Set `interceptor`: call `DataSpilloverInterceptor` with the arguments shown to perform the current operation. +- **Line 319:** `context = ExecutionContext(request_id="1", auth=AuthContext(subject="alice"), logger=MagicMock())` — Set `context`: construct a fresh per-call context; its request ID identifies the operation while identity is populated separately from trusted request sources. +- **Line 320:** `app = make_app(interceptor)` — Set `app`: construct the real one-tool SDK application used for this test scenario. +### `test_models_mcp_results_and_widget_bypass.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 321:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 322:** `for value in [ToolInput(text="full"), types.CallToolResult(` — Iterate `value` through `[ToolInput(text='full'), types.CallToolResult(content=[types.TextContent(type='text', text='full')], structuredContent={'text': 'full'})]`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 323:** `content=[types.TextContent(type="text", text="full")], structuredContent={"text": "full"},` — Iterate `value` through `[ToolInput(text='full'), types.CallToolResult(content=[types.TextContent(type='text', text='full')], structuredContent={'text': 'full'})]`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 324:** `)]:` — Iterate `value` through `[ToolInput(text='full'), types.CallToolResult(content=[types.TextContent(type='text', text='full')], structuredContent={'text': 'full'})]`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +### `test_models_mcp_results_and_widget_bypass.scenario.handler` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 325:** `async def handler():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 326:** `return value` — Return from this operation: evaluate `value`. No following statements in this call execute. +### `test_models_mcp_results_and_widget_bypass.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 327:** `spilled = await interceptor.intercept(context, handler)` — Set `spilled`: await call `interceptor.intercept` with the arguments shown to perform the current operation. +- **Line 328:** `payload = await store.get(spilled.resource_uri.rsplit("/", 1)[1], owner=caller_owner(context))` — Set `payload` to the complete serialized result as immutable bytes: await look up the requested entry under the supplied ownership constraint. +- **Line 329:** `assert json.loads(payload.payload) == value.model_dump(mode="json", by_alias=True, exclude_none=True)` — Assert `json.loads(payload.payload) == value.model_dump(mode='json', by_alias=True, exclude_none=True)`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 330:** `component = MagicMock()` — Set `component`: call `MagicMock` with the arguments shown to perform the current operation. +- **Line 331:** `result = app._to_call_tool_result(spilled, component, context)` — Set `result`: call `app._to_call_tool_result` with the arguments shown to perform the current operation. +- **Line 332:** `assert result.structured_content["resource_uri"] == spilled.resource_uri` — Assert `result.structured_content['resource_uri'] == spilled.resource_uri`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 333:** `component.transformer.assert_not_called()` — Prove that this potentially destructive or incompatible operation was never invoked. +### `test_models_mcp_results_and_widget_bypass` + +Check JSON-compatible serialization of Pydantic models and explicit MCP results. Verify the internal spillover wrapper converts to a link without passing through a widget transformer that expects the original data. + +- **Line 334:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 335:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 336:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_background_task_spills_and_can_be_read` + +Create an actual background tool task, wait a bounded time for completion, inspect its saved result, and read its spillover URI using the initiating session. + +- **Line 337:** `def test_background_task_spills_and_can_be_read():` — Define this callable. Create an actual background tool task, wait a bounded time for completion, inspect its saved result, and read its spillover URI using the initiating session. +- **Line 338:** `app = make_app(DataSpilloverInterceptor(threshold_bytes=1), task_support="optional")` — Set `app`: construct the real one-tool SDK application used for this test scenario. +- **Line 339:** `rc = RequestContext(session=Session())` — Set `rc`: call `RequestContext` with the arguments shown to perform the current operation. +### `test_background_task_spills_and_can_be_read.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 340:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 341:** `result = await call(app, rc, task=types.TaskMetadata(ttl=60000))` — Set `result`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 342:** `for _ in range(100):` — Iterate `_` through `range(100)`. Each iteration applies the indented behavior to one entry, interceptor, caller, or test case. +- **Line 343:** `entry = await app.task_manager._store.get(result.task.task_id)` — Set `entry`: await look up the requested entry under the supplied ownership constraint. +- **Line 344:** `if entry.result is not None:` — Check evaluate `entry.result is not None`; the branch runs only when this comparison is true. The indented body handles this case; other results continue past it. +- **Line 345:** `break` — Stop polling as soon as the background task has produced its stored result. +- **Line 346:** `await asyncio.sleep(0.01)` — Await yield to the background task before checking its completion again. +- **Line 347:** `assert entry.result is not None` — Assert `entry.result is not None`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 348:** `stored = entry.result` — Set `stored`: evaluate `entry.result`. +- **Line 349:** `data = stored.structured_content if isinstance(stored, types.CallToolResult) else stored["structuredContent"]` — Set `data`: select `stored.structured_content` when `isinstance(stored, types.CallToolResult)` is true; otherwise select `stored['structuredContent']`. +- **Line 350:** `assert json.loads(await read(app, rc, data["resource_uri"])) == {"text": "large result"}` — Assert `json.loads(await read(app, rc, data['resource_uri'])) == {'text': 'large result'}`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +### `test_background_task_spills_and_can_be_read` + +Create an actual background tool task, wait a bounded time for completion, inspect its saved result, and read its spillover URI using the initiating session. + +- **Line 351:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 352:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 353:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_opaque_oauth_identity_reads_resources` + +Exercise opaque-token introspection through the configured OAuth service. Verified identity allows spillover without a session; a subsequently inactive token cannot read the saved result. + +- **Line 354:** `def test_opaque_oauth_identity_reads_resources(monkeypatch):` — Define this callable. Exercise opaque-token introspection through the configured OAuth service. Verified identity allows spillover without a session; a subsequently inactive token cannot read the saved result. +- **Line 355:** `from nitrostack.auth.oauth import OAuthService` — Import the named APIs from `nitrostack.auth.oauth` so this file can use the shared SDK, library, or test implementation. +- **Line 356:** `from unittest.mock import AsyncMock` — Import the named APIs from `unittest.mock` so this file can use the shared SDK, library, or test implementation. +- **Line 357:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 358:** `monkeypatch.setattr("nitrostack.auth.request.verify_bearer_payload", lambda token: None)` — Replace this dependency for the duration of the pytest fixture. +- **Line 359:** `service = MagicMock()` — Set `service`: call `MagicMock` with the arguments shown to perform the current operation. +- **Line 360:** `service.introspect_token = AsyncMock(return_value={"active": True, "sub": "alice", "iss": "oauth"})` — Set `service.introspect_token`: call `AsyncMock` with the arguments shown to perform the current operation. +- **Line 361:** `DIContainer.get_instance().register_value(OAuthService, service)` — Register this preconfigured service instance with dependency injection. +- **Line 362:** `app = make_app(DataSpilloverInterceptor(threshold_bytes=1))` — Set `app`: construct the real one-tool SDK application used for this test scenario. +- **Line 363:** `rc = RequestContext(request=SimpleNamespace(headers={"authorization": "Bearer opaque"}))` — Set `rc`: call `RequestContext` with the arguments shown to perform the current operation. +### `test_opaque_oauth_identity_reads_resources.scenario` + +These lines connect this operation to the storage and resource flow described above. + +- **Line 364:** `async def scenario():` — Define an asynchronous operation that callers await. Its body below carries out the surrounding operation; this line defines it without running it. +- **Line 365:** `result = await call(app, rc)` — Set `result`: await invoke the application's registered tool-call path under the chosen request context. +- **Line 366:** `uri = result.structured_content["resource_uri"]` — Set `uri`: evaluate `result.structured_content['resource_uri']`. +- **Line 367:** `assert json.loads(await read(app, rc, uri)) == {"text": "large result"}` — Assert `json.loads(await read(app, rc, uri)) == {'text': 'large result'}`. A false result fails this test immediately, checking the behavior described for `scenario` rather than silently accepting a regression. +- **Line 368:** `service.introspect_token.return_value = {"active": False}` — Set `service.introspect_token.return_value`: evaluate `{'active': False}`. +- **Line 369:** `with pytest.raises(ResourceNotFoundError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 370:** `await read(app, rc, uri)` — Await invoke the application's registered mcp resource-read path under the chosen caller context. +### `test_opaque_oauth_identity_reads_resources` + +Exercise opaque-token introspection through the configured OAuth service. Verified identity allows spillover without a session; a subsequently inactive token cannot read the saved result. + +- **Line 371:** `asyncio.run(scenario())` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 372:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 373:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_file_symlink_check_without_os_privileges` + +Simulate the path library reporting a symlink so the rejection branch is covered even on Windows machines that cannot create real links. The separate real-link test covers the actual filesystem behavior where supported. + +- **Line 374:** `def test_file_symlink_check_without_os_privileges(tmp_path, monkeypatch):` — Define this callable. Simulate the path library reporting a symlink so the rejection branch is covered even on Windows machines that cannot create real links. The separate real-link test covers the actual filesystem behavior where supported. +- **Line 375:** `from pathlib import Path` — Import the named APIs from `pathlib` so this file can use the shared SDK, library, or test implementation. +- **Line 376:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 377:** `store = FileSpilloverStore(tmp_path)` — Set `store`: call `FileSpilloverStore` with the arguments shown to perform the current operation. +- **Line 378:** `monkeypatch.setattr(Path, "is_symlink", lambda path: path.name == "a" * 32 + ".json")` — Replace this dependency for the duration of the pytest fixture. +- **Line 379:** `with pytest.raises(ValueError, match="escapes"):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 380:** `asyncio.run(store.get("a" * 32, owner="a"))` — Create an event loop, execute this test scenario to completion, and close the loop. +- **Line 381:** `(blank)` — Blank separator for readability; it performs no operation. +- **Line 382:** `(blank)` — Blank separator for readability; it performs no operation. +### `test_invalid_interceptor_configuration` + +Reject negative or non-integral byte thresholds and prefixes that are relative or contain a query component. + +- **Line 383:** `@pytest.mark.parametrize("kwargs", [{"threshold_bytes": -1}, {"threshold_bytes": 0.5},` — Ask pytest to run this test once for each supplied case, so distinct shapes or invalid inputs are checked independently. +- **Line 384:** `{"resource_uri_prefix": "../results/"},` — Continue the preceding Python declaration or expression with the literal name or value shown. +- **Line 385:** `{"resource_uri_prefix": "resource://results/?x/"}])` — Continue the preceding Python declaration or expression with the literal name or value shown. +- **Line 386:** `def test_invalid_interceptor_configuration(kwargs):` — Define this callable. Reject negative or non-integral byte thresholds and prefixes that are relative or contain a query component. +- **Line 387:** `with pytest.raises(ValueError):` — Require the operation inside this block to raise the specified exception; a successful unsafe operation causes the test to fail. +- **Line 388:** `DataSpilloverInterceptor(**kwargs)` — Call `dataspilloverinterceptor` with the arguments shown to perform the current operation. + +## README usage examples, line by line + +The README contains additional example code rather than runtime SDK implementation. The Python and JSON examples added for this feature are explained here too. + +- **README line 699:** `from pydantic import BaseModel` — Import the public SDK or Pydantic APIs used by the example; users do not need private implementation imports. +- **README line 700:** `from nitrostack import DataSpilloverInterceptor, MemorySpilloverStore, tool, use_interceptors` — Import the public SDK or Pydantic APIs used by the example; users do not need private implementation imports. +- **README line 701:** `(blank)` — Separate the example into readable declarations; this line has no runtime effect. +- **README line 702:** `spillover = DataSpilloverInterceptor(` — Create one configured interceptor instance that the tool decorator below will reuse for calls. +- **README line 703:** `threshold_bytes=64 * 1024,` — Set the spill threshold to 64 × 1024 = 65,536 serialized bytes. Equal-sized results remain unchanged; larger results move into the store. +- **README line 704:** `store=MemorySpilloverStore(` — Choose RAM storage, which provides TTL and LRU eviction and does not survive a process restart. +- **README line 705:** `max_bytes=10 * 1024 * 1024,` — Allow one decoded stored result to occupy at most 10 MiB; exceeding this raises PayloadTooLargeError rather than evicting enough entries to accept it. +- **README line 706:** `max_total_bytes=100 * 1024 * 1024,` — Set a 100 MiB store capacity. RAM counts payload bytes; the file store counts actual JSON document bytes including base64 and metadata overhead. +- **README line 707:** `ttl_seconds=300,` — Keep each result available for five minutes from its insertion time. Reads never extend this lifetime. +- **README line 708:** `),` — Close the surrounding constructor call or JSON object. This punctuation completes the example syntax without adding a separate operation. +- **README line 709:** `resource_uri_prefix="resource://data-spillover/",` — Use the default MCP resource-address namespace; the store-generated opaque ID is appended to this prefix. +- **README line 710:** `)` — Close the surrounding constructor call or JSON object. This punctuation completes the example syntax without adding a separate operation. +- **README line 711:** `(blank)` — Separate the example into readable declarations; this line has no runtime effect. +- **README line 712:** `class ReportInput(BaseModel):` — Define the Pydantic model used to validate the incoming report tool arguments. +- **README line 713:** `count: int = 10000` — Accept an integer row count and default it to 10,000 when the client omits the argument. +- **README line 714:** `(blank)` — Separate the example into readable declarations; this line has no runtime effect. +- **README line 715:** `class ReportTools:` — Group the report method in a provider/controller class that must be registered in the user application module. +- **README line 716:** `@tool(name="report", description="Generate a report", input_schema=ReportInput)` — Expose the report method as an MCP tool, publish its description, and use ReportInput for argument validation. +- **README line 717:** `@use_interceptors(spillover)` — Attach the configured spillover instance to this method. The pipeline will run it around each report call. +- **README line 718:** `async def report(self, input: ReportInput, context):` — Declare the asynchronous report handler. The SDK passes validated input and an execution context containing caller information. +- **README line 719:** `return [{"row": index} for index in range(input.count)]` — Build the complete list of row dictionaries. The interceptor measures the returned JSON, saves it if oversized, and replaces it with a small preview and URI. +- **README line 730:** `{` — Close the surrounding constructor call or JSON object. This punctuation completes the example syntax without adding a separate operation. +- **README line 731:** `"jsonrpc": "2.0",` — Identify the request as a JSON-RPC 2.0 message. +- **README line 732:** `"id": 2,` — Assign the client's request identifier so it can associate the resource response with this request. +- **README line 733:** `"method": "resources/read",` — Ask the MCP server to read a resource rather than invoke the original tool again. +- **README line 734:** `"params": {"uri": "resource://data-spillover/"}` — Supply the exact resource_uri returned by the earlier tool result. The angle-bracket text is a placeholder, and knowing a URI alone does not bypass caller ownership checks. +- **README line 735:** `}` — Close the surrounding constructor call or JSON object. This punctuation completes the example syntax without adding a separate operation. +- **README line 746:** `from nitrostack import FileSpilloverStore` — Import the public SDK or Pydantic APIs used by the example; users do not need private implementation imports. +- **README line 747:** `(blank)` — Separate the example into readable declarations; this line has no runtime effect. +- **README line 748:** `store = FileSpilloverStore(` — Create the disk-backed alternative; pass this instance as the interceptor's store when persistence is desired. +- **README line 749:** `".nitrostack/spillover",` — Choose a storage directory relative to the running server's working directory. The store resolves it to an absolute root and confines generated files there. +- **README line 750:** `max_bytes=10 * 1024 * 1024,` — Allow one decoded stored result to occupy at most 10 MiB; exceeding this raises PayloadTooLargeError rather than evicting enough entries to accept it. +- **README line 751:** `max_total_bytes=100 * 1024 * 1024,` — Set a 100 MiB store capacity. RAM counts payload bytes; the file store counts actual JSON document bytes including base64 and metadata overhead. +- **README line 752:** `ttl_seconds=300,` — Keep each result available for five minutes from its insertion time. Reads never extend this lifetime. +- **README line 753:** `)` — Close the surrounding constructor call or JSON object. This punctuation completes the example syntax without adding a separate operation. + +## Changes to existing behavior + +- Class-based interceptors still resolve through the singleton DI container. Configured instances are accepted directly; only interceptor resolution changes, not guards, middleware, pipes, or filters. +- Undecorated tools retain their original behavior and output schema. Decorated tools advertise an alternative spillover envelope when they already have an output schema. +- Resource-not-found is the common outward error for expired, unknown, malformed, or unauthorized spillover addresses. A storage write error becomes the existing tool-error response; the large result is not sent as a fallback. +- Widgets receive ordinary data as before until a result spills. A spilled result bypasses the widget transformer because its envelope is not the original tool data. +- The previous feature branch and stash are preserved. The implementation branch is feature/issue-67-data-spillover. + +Total Python lines explained: **879**. The README adds usage examples and operational constraints; it is documentation, rather than another executable implementation. + +## Verification results + +- Spillover acceptance tests: **38 passed, 1 skipped**. The real symlink test is skipped because this Windows account cannot create symlinks. A separate test simulates symlink detection and passes. +- Initial focused regression set (spillover, pipeline ordering, testing harness, tool schemas): **46 passed, 1 skipped**, before the additional task/OAuth/expiry acceptance tests were added. +- Full suite with coverage, after refreshing the editable SDK version metadata: **868 passed, 1 failed, 1 skipped**, with **85.80%** total coverage. This exceeds the repository's configured 50% coverage floor. +- The remaining failure was `tests/test_oauth.py::test_discovery_dcr_404_when_disabled_or_missing_client_id`, where Windows aborted a localhost HTTP connection with WinError 10053. +- The entire OAuth test module passed separately (**34 tests**). An unchanged `main` archive also passed its full suite (**831 tests**, 85.41% coverage). These results do not establish a specific root cause for the full-run connection failure. +- A subsequent complete run without coverage produced **867 passed, 2 failed, 1 skipped**: the same OAuth test and `test_discovery_server_idempotent_start_and_dcr_http` failed with the same connection-aborted error. The feature's acceptance tests continued to pass. +- The full repository suite therefore is **not entirely green on this Windows host**. No production OAuth code or existing OAuth tests were changed to mask that result. +- `git diff --check` passed. At the time of verification, the implementation was uncommitted and unpushed on `feature/issue-67-data-spillover`. + +The local virtual environment needed the already-declared `pytest-timeout` development dependency. Its editable SDK installation was refreshed from stale version 0.3.2 to the pulled source version 0.4.2; this resolved an unrelated upgrade-test version mismatch without changing dependency declarations. diff --git a/nitrostack/__init__.py b/nitrostack/__init__.py index 9f1c3d5..835cf81 100644 --- a/nitrostack/__init__.py +++ b/nitrostack/__init__.py @@ -1,5 +1,15 @@ # NitroStack Python SDK Public API Exports +from nitrostack.core.interceptors import ( + DataSpilloverInterceptor, + FileSpilloverStore, + MemorySpilloverStore, + PayloadTooLargeError, + SpilloverEntry, + SpilloverStore, + generate_preview, +) + from nitrostack.core.decorators import ( tool, resource, @@ -139,6 +149,13 @@ __all__ = [ + "DataSpilloverInterceptor", + "FileSpilloverStore", + "MemorySpilloverStore", + "PayloadTooLargeError", + "SpilloverEntry", + "SpilloverStore", + "generate_preview", "tool", "resource", "prompt", diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 9d9b069..0d0443e 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -40,6 +40,10 @@ ) from nitrostack.core.mcp_server import NitroStackMcpServer from nitrostack.core.pipeline import run_pipeline +from nitrostack.core.interceptors.data_spillover import ( + DataSpilloverInterceptor, SPILLOVER_SCHEMA, SpilloverRegistry, SpilloverResult, + bind_spillover_context, caller_owner, +) from nitrostack.core.additional_decorators import HealthCheckRegistry from nitrostack.core.task import TaskManager, TaskStatus from nitrostack.events.event_emitter import EventEmitter @@ -598,6 +602,7 @@ def __init__(self, app_class: Type): self._initial_tools: List[Tuple[Any, Callable, ToolConfig]] = [] self.task_manager = TaskManager() self._in_flight = InFlightRegistry() + self._spillover = SpilloverRegistry() self._bootstrap() @@ -669,6 +674,11 @@ def _bootstrap(self) -> None: # 5. Wire every protocol handler (tools/resources/prompts/tasks/notifications) # onto the owned low-level server. This is factored out so additional server # instances (e.g. one per HTTP session) can be configured identically. + for entry in self._tools.values(): + for candidate in getattr(entry.method, "_mcp_interceptors", []): + interceptor = container.resolve(candidate) if isinstance(candidate, type) else candidate + if isinstance(interceptor, DataSpilloverInterceptor): + self._spillover.register(interceptor) self._setup_handlers(self.mcp_server) def _assert_declared_dependencies( @@ -1114,6 +1124,11 @@ def _build_tool_definition(self, entry: _ToolEntry) -> types.Tool: output_schema = tool_json_schema(cfg.output_schema) if output_schema is not None: + if self._has_spillover(entry.method): + output_schema = { + "type": "object", "anyOf": [output_schema, SPILLOVER_SCHEMA], + **({"$defs": output_schema["$defs"]} if "$defs" in output_schema else {}), + } tool_kwargs["outputSchema"] = output_schema return types.Tool(**tool_kwargs) @@ -1158,7 +1173,33 @@ def _build_prompt_definition(self, entry: _PromptEntry) -> types.Prompt: # Request dispatch # ------------------------------------------------------------------ - def _pipeline_stages(self, method: Callable) -> Tuple[List[Type], List[Type], List[Type], List[Type], List[Type]]: + def _has_spillover(self, method: Callable) -> bool: + container = DIContainer.get_instance() + return any( + isinstance(container.resolve(item) if isinstance(item, type) else item, + DataSpilloverInterceptor) + for item in getattr(method, "_mcp_interceptors", []) + ) + + async def _bind_spillover(self, context: ExecutionContext, rc: Any) -> None: + if not self._spillover.routes: + return + # OAuth guards may verify opaque tokens as well as the shared JWT path. + from nitrostack.auth.request import authorization_token_from_request, auth_context_from_payload + from nitrostack.auth.oauth import OAuthService + + token = authorization_token_from_request(rc) + container = DIContainer.get_instance() + if context.auth is None and token and container.has_value(OAuthService): + try: + payload = await container.resolve(OAuthService).introspect_token(token) + if payload.get("active"): + context.auth = auth_context_from_payload(payload) + except Exception: + pass # Invalid credentials remain denied by caller_owner. + bind_spillover_context(context, rc, self._spillover) + + def _pipeline_stages(self, method: Callable) -> Tuple[List[Any], List[Any], List[Any], List[Any], List[Any]]: return ( getattr(method, "_mcp_guards", []), getattr(method, "_mcp_middleware", []), @@ -1173,6 +1214,8 @@ def _to_call_tool_result( component: Optional[Component] = None, context: Optional[ExecutionContext] = None, ) -> types.CallToolResult: + if isinstance(result, SpilloverResult): + return result.to_mcp() if isinstance(result, InputRequiredResult): wire = result.to_wire_dict() return types.CallToolResult( @@ -1371,6 +1414,7 @@ async def background_execution(): trace=trace, ) _apply_request_envelope(task_ctx, rc) + await self._bind_spillover(task_ctx, rc) task_ctx.task = TaskContext( task_id, self.task_manager, @@ -1425,6 +1469,7 @@ async def background_execution(): trace=trace, ) _apply_request_envelope(ctx, rc) + await self._bind_spillover(ctx, rc) ticket = self._in_flight.register(correlation_id, jsonrpc_id=jsonrpc_id) try: result = await run_pipeline( @@ -1457,6 +1502,21 @@ async def background_execution(): return self._to_call_tool_result(result, entry.component, ctx) async def _read_resource(self, uri: str) -> List[ReadResourceContents]: + for prefix, store in self._spillover.routes.items(): + if uri.startswith(prefix): + ctx = ExecutionContext(request_id=str(uuid.uuid4())) + rc = request_ctx.get(None) + _apply_request_envelope(ctx, rc) + await self._bind_spillover(ctx, rc) + try: + entry = await store.get(uri[len(prefix):], owner=caller_owner(ctx)) + except (PermissionError, ValueError): + raise ResourceNotFoundError(uri) from None + if entry is None: + raise ResourceNotFoundError(uri) + return [ReadResourceContents( + content=entry.payload.decode("utf-8"), mime_type=entry.mime_type, + )] entry = self._resources.get(uri) path_kwargs: Dict[str, str] = {} diff --git a/nitrostack/core/context.py b/nitrostack/core/context.py index 42cf129..16c0829 100644 --- a/nitrostack/core/context.py +++ b/nitrostack/core/context.py @@ -197,6 +197,8 @@ class ExecutionContext: rpc_meta: Optional["RequestMeta"] = None mcp_headers: Dict[str, str] = field(default_factory=dict) mcp_param_headers: Dict[str, str] = field(default_factory=dict) + spillover_session_key: Optional[str] = field(default=None, repr=False) + spillover_invalid_auth: bool = field(default=False, repr=False) def __post_init__(self) -> None: if not self.correlation_id: diff --git a/nitrostack/core/errors.py b/nitrostack/core/errors.py index b5f1c50..97824a1 100644 --- a/nitrostack/core/errors.py +++ b/nitrostack/core/errors.py @@ -1,6 +1,10 @@ from typing import Any +class PayloadTooLargeError(ValueError): + """A serialized tool result exceeds the spillover store's per-result limit.""" + + class ToolExecutionError(Exception): """Raised when tool execution fails.""" pass diff --git a/nitrostack/core/interceptors/__init__.py b/nitrostack/core/interceptors/__init__.py new file mode 100644 index 0000000..4344182 --- /dev/null +++ b/nitrostack/core/interceptors/__init__.py @@ -0,0 +1,15 @@ +"""Built-in tool interceptors and spillover storage adapters.""" + +from .data_spillover import DataSpilloverInterceptor, generate_preview +from .spillover import ( + FileSpilloverStore, + MemorySpilloverStore, + PayloadTooLargeError, + SpilloverEntry, + SpilloverStore, +) + +__all__ = [ + "DataSpilloverInterceptor", "FileSpilloverStore", "MemorySpilloverStore", + "PayloadTooLargeError", "SpilloverEntry", "SpilloverStore", "generate_preview", +] diff --git a/nitrostack/core/interceptors/data_spillover.py b/nitrostack/core/interceptors/data_spillover.py new file mode 100644 index 0000000..5380ae0 --- /dev/null +++ b/nitrostack/core/interceptors/data_spillover.py @@ -0,0 +1,168 @@ +"""Tool-result spillover, bounded previews, and trusted caller ownership.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +import weakref +from dataclasses import dataclass +from itertools import islice +from typing import Any +from urllib.parse import urlsplit + +import mcp.types as types +from pydantic import BaseModel + +from nitrostack.auth.request import authorization_token_from_request, tenant_from_claims +from nitrostack.core.context import ExecutionContext +from nitrostack.protocol.mrtr import InputRequiredResult +from nitrostack.core.interceptors.spillover import MemorySpilloverStore, SpilloverStore + + +def generate_preview(value: Any, *, max_bytes: int = 2048) -> dict: + """Include head items and counts, shrinking even deeply nested values to fit.""" + if max_bytes < 128: + raise ValueError("Preview budget must be at least 128 bytes") + if isinstance(value, list): + preview = {"type": "list", "count": len(value), "head": value[:5], "truncated": len(value) > 5} + elif isinstance(value, dict): + preview = {"type": "dict", "count": len(value), "head": dict(islice(value.items(), 5)), + "truncated": len(value) > 5} + else: + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) + preview = {"type": "string", "count": len(text), "text": text[:512], "truncated": len(text) > 512} + encoded = json.dumps(preview, ensure_ascii=False, default=str).encode("utf-8") + if len(encoded) > max_bytes: + text = json.dumps(preview.get("head", preview.get("text")), ensure_ascii=False, default=str) + preview.pop("head", None) + preview["text"] = text[:512] + preview["truncated"] = True + while len(json.dumps(preview, ensure_ascii=False, default=str).encode("utf-8")) > max_bytes: + preview["text"] = preview["text"][:len(preview["text"]) // 2] + return preview + + +@dataclass(frozen=True) +class SpilloverResult: + preview: dict + resource_uri: str + original_size_bytes: int + + def to_mcp(self) -> types.CallToolResult: + data = dict(preview=self.preview, resource_uri=self.resource_uri, + original_size_bytes=self.original_size_bytes) + return types.CallToolResult( + content=[ + types.TextContent(type="text", text=json.dumps(data, ensure_ascii=False, default=str)), + types.ResourceLink(type="resource_link", uri=self.resource_uri, + name="Full tool result"), + ], + structuredContent=data, isError=False, + ) + + +SPILLOVER_SCHEMA = { + "type": "object", + "properties": { + "preview": {"type": "object"}, + "resource_uri": {"type": "string"}, + "original_size_bytes": {"type": "integer", "minimum": 0}, + }, + "required": ["preview", "resource_uri", "original_size_bytes"], +} + + +class SpilloverRegistry: + """Per-application routes and weakly held anonymous session identities.""" + + def __init__(self): + self.routes: dict[str, SpilloverStore] = {} + self._sessions: dict[int, tuple[weakref.ref, str]] = {} + + def register(self, interceptor): + prefix, store = interceptor.resource_uri_prefix, interceptor.store + for existing, registered in self.routes.items(): + if prefix.startswith(existing) or existing.startswith(prefix): + if prefix != existing or store is not registered: + raise ValueError("Conflicting spillover resource prefixes") + self.routes[prefix] = store + + def session_key(self, rc): + session = getattr(rc, "session", None) + if session is None: + return None + identity = id(session) + existing = self._sessions.get(identity) + if existing is not None and existing[0]() is session: + return existing[1] + key = uuid.uuid4().hex + try: + reference = weakref.ref(session, lambda _: self._sessions.pop(identity, None)) + except TypeError: + return None + self._sessions[identity] = (reference, key) + return key + + +def caller_owner(context: ExecutionContext) -> str: + auth = context.auth + if auth is not None and (auth.subject or auth.client_id): + identity = ["principal", auth.iss, auth.subject, auth.client_id, + tenant_from_claims(auth.claims)] + elif context.spillover_invalid_auth: + raise PermissionError("Verified identity required for spillover") + elif context.spillover_session_key: + identity = ["session", context.spillover_session_key] + else: + raise PermissionError("Spillover requires a verified caller or trusted MCP session") + return hashlib.sha256(json.dumps(identity).encode("utf-8")).hexdigest() + + +def bind_spillover_context(context, rc, registry): + context.spillover_session_key = registry.session_key(rc) + context.spillover_invalid_auth = bool( + authorization_token_from_request(rc) and context.auth is None + ) + + +class DataSpilloverInterceptor: + def __init__( + self, threshold_bytes: int = 64 * 1024, store: SpilloverStore | None = None, + resource_uri_prefix: str = "resource://data-spillover/", + ): + if type(threshold_bytes) is not int or threshold_bytes < 0: + raise ValueError("Spillover threshold must be a nonnegative integer") + parts = urlsplit(resource_uri_prefix) + if (not parts.scheme or not parts.netloc or parts.query or parts.fragment + or not resource_uri_prefix.endswith("/")): + raise ValueError("Resource prefix must be an absolute URI ending in '/'") + self.threshold_bytes = threshold_bytes + self.store = store if store is not None else MemorySpilloverStore() + self.resource_uri_prefix = resource_uri_prefix + + async def intercept(self, context, next_fn): + result = await next_fn() + if isinstance(result, (InputRequiredResult, SpilloverResult)): + return result + if isinstance(result, dict) and result.get("resultType") == "input_required": + return result + value = result + if isinstance(value, types.CallToolResult): + if value.is_error or value.result_type == "input_required": + return result + value = value.model_dump(mode="json", by_alias=True, exclude_none=True) + elif isinstance(value, dict) and value.get("isError"): + return result + elif isinstance(value, BaseModel): + value = value.model_dump(mode="json") + if isinstance(value, str): + payload, mime = value.encode("utf-8"), "text/plain" + else: + payload = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8") + mime = "application/json" + if len(payload) <= self.threshold_bytes: + return result + owner = caller_owner(context) + key = await self.store.put(payload, owner=owner, mime_type=mime) + return SpilloverResult(generate_preview(value), self.resource_uri_prefix + key, len(payload)) diff --git a/nitrostack/core/interceptors/spillover.py b/nitrostack/core/interceptors/spillover.py new file mode 100644 index 0000000..7ab5482 --- /dev/null +++ b/nitrostack/core/interceptors/spillover.py @@ -0,0 +1,218 @@ +"""Bounded, caller-scoped storage for serialized tool results.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import math +import os +import re +import threading +import time +import uuid +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from nitrostack.core.errors import PayloadTooLargeError + + +@dataclass(frozen=True) +class SpilloverEntry: + payload: bytes + owner: str + mime_type: str + expires_at: float + + +class SpilloverStore(Protocol): + max_bytes: int + ttl_seconds: float + + async def put( + self, payload: bytes, *, owner: str, mime_type: str, + ttl_seconds: float | None = None, + ) -> str: ... + + async def get(self, key: str, *, owner: str) -> SpilloverEntry | None: ... + + async def delete(self, key: str, *, owner: str) -> bool: ... + + async def cleanup_expired(self) -> int: ... + + +def _valid_key(key: str) -> bool: + return isinstance(key, str) and re.fullmatch(r"[0-9a-f]{32}", key) is not None + + +class MemorySpilloverStore: + """Thread-safe payload cache with absolute TTL and LRU eviction.""" + + def __init__( + self, *, max_bytes: int = 10 * 1024 * 1024, + max_total_bytes: int = 100 * 1024 * 1024, ttl_seconds: float = 300, + ): + if any(type(limit) is not int or limit <= 0 for limit in (max_bytes, max_total_bytes)): + raise ValueError("Storage limits must be positive integers") + if not math.isfinite(ttl_seconds) or ttl_seconds <= 0: + raise ValueError("TTL must be positive and finite") + self.max_bytes = max_bytes + self.max_total_bytes = max_total_bytes + self.ttl_seconds = ttl_seconds + self._entries: OrderedDict[str, SpilloverEntry] = OrderedDict() + self._lock = threading.RLock() + self._size = 0 + + def _entry(self, payload, owner, mime_type, ttl_seconds): + if not isinstance(payload, bytes): + raise TypeError("Store payload must be bytes") + if not owner: + raise ValueError("A caller owner is required") + if len(payload) > self.max_bytes: + raise PayloadTooLargeError(f"Payload exceeds max_bytes={self.max_bytes}") + ttl = self.ttl_seconds if ttl_seconds is None else ttl_seconds + if not math.isfinite(ttl) or ttl <= 0: + raise ValueError("TTL must be positive and finite") + return SpilloverEntry(payload, owner, mime_type, time.time() + ttl) + + def _remove(self, key): + entry = self._entries.pop(key) + self._size -= len(entry.payload) + + def _cleanup(self): + expired = [key for key, entry in self._entries.items() if entry.expires_at <= time.time()] + for key in expired: + self._remove(key) + return len(expired) + + async def put(self, payload, *, owner, mime_type, ttl_seconds=None): + entry = self._entry(payload, owner, mime_type, ttl_seconds) + if len(payload) > self.max_total_bytes: + raise ValueError("Payload exceeds total store capacity") + with self._lock: + self._cleanup() + while self._size + len(payload) > self.max_total_bytes: + self._remove(next(iter(self._entries))) + key = uuid.uuid4().hex + self._entries[key] = entry + self._size += len(payload) + return key + + async def get(self, key, *, owner): + with self._lock: + self._cleanup() + entry = self._entries.get(key) if _valid_key(key) else None + if entry is None or entry.owner != owner: + return None + self._entries.move_to_end(key) + return entry + + async def delete(self, key, *, owner): + with self._lock: + entry = self._entries.get(key) if _valid_key(key) else None + if entry is None or entry.owner != owner: + return False + self._remove(key) + return True + + async def cleanup_expired(self): + with self._lock: + return self._cleanup() + + +class FileSpilloverStore(MemorySpilloverStore): + """Atomic disk store. Each root must be owned by one store instance/process. + + Total capacity counts actual JSON file bytes, including base64 and metadata. + File operations run in worker threads; no background cleanup task is needed. + """ + + def __init__(self, root: str | Path, **kwargs): + super().__init__(**kwargs) + self.root = Path(root).resolve() + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + self._cleanup_files() + + def _path(self, key): + if not _valid_key(key): + raise ValueError("Invalid spillover ID") + path = self.root / f"{key}.json" + if path.is_symlink() or path.resolve().parent != self.root: + raise ValueError("Spillover path escapes storage root") + return path + + def _read(self, path): + raw = json.loads(path.read_text(encoding="utf-8")) + return SpilloverEntry( + base64.b64decode(raw["payload"], validate=True), raw["owner"], + raw["mime_type"], raw["expires_at"], + ) + + def _cleanup_files(self): + count = 0 + for candidate in self.root.glob("*.json"): + if not _valid_key(candidate.stem): + continue + path = self._path(candidate.stem) + if self._read(path).expires_at <= time.time(): + path.unlink() + count += 1 + return count + + def _put_file(self, payload, owner, mime_type, ttl_seconds): + entry = self._entry(payload, owner, mime_type, ttl_seconds) + encoded = json.dumps({ + "payload": base64.b64encode(payload).decode("ascii"), + "owner": entry.owner, "mime_type": entry.mime_type, + "expires_at": entry.expires_at, + }).encode("utf-8") + with self._lock: + self._cleanup_files() + used = sum(self._path(p.stem).stat().st_size for p in self.root.glob("*.json") + if _valid_key(p.stem)) + if used + len(encoded) > self.max_total_bytes: + raise ValueError("File store capacity exceeded") + key = uuid.uuid4().hex + target = self._path(key) + temporary = self.root / f"{key}.tmp" + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(encoded) + temporary.replace(target) + finally: + temporary.unlink(missing_ok=True) + return key + + def _get_file(self, key, owner, delete=False): + with self._lock: + path = self._path(key) + self._cleanup_files() + if not path.exists(): + return None + entry = self._read(path) + if entry.expires_at <= time.time(): + path.unlink() + return None + if entry.owner != owner: + return None + if delete: + path.unlink() + return entry + + async def put(self, payload, *, owner, mime_type, ttl_seconds=None): + return await asyncio.to_thread(self._put_file, payload, owner, mime_type, ttl_seconds) + + async def get(self, key, *, owner): + return await asyncio.to_thread(self._get_file, key, owner) + + async def delete(self, key, *, owner): + return await asyncio.to_thread(self._get_file, key, owner, True) is not None + + async def cleanup_expired(self): + def cleanup(): + with self._lock: + return self._cleanup_files() + return await asyncio.to_thread(cleanup) diff --git a/nitrostack/core/pipeline.py b/nitrostack/core/pipeline.py index 6bc5091..1b0a765 100644 --- a/nitrostack/core/pipeline.py +++ b/nitrostack/core/pipeline.py @@ -40,7 +40,7 @@ def decorator(func: Callable): return func return decorator -def use_interceptors(*interceptors: Type[Interceptor]): +def use_interceptors(*interceptors: Type[Interceptor] | Interceptor): def decorator(func: Callable): func._mcp_interceptors = getattr(func, "_mcp_interceptors", []) + list(interceptors) return func @@ -204,7 +204,7 @@ async def run_pipeline( context: ExecutionContext, guards: List[Type[Guard]], middleware: List[Type[Middleware]], - interceptors: List[Type[Interceptor]], + interceptors: List[Type[Interceptor] | Interceptor], pipes: List[Type[Pipe]], filters: List[Type[ExceptionFilter]], param_name: str = "input", @@ -248,7 +248,10 @@ async def call_target(): # Middleware -> Interceptors -> Handler current_next = call_target for interceptor_cls in reversed(interceptors): - interceptor = container.resolve(interceptor_cls) + interceptor = ( + container.resolve(interceptor_cls) + if isinstance(interceptor_cls, type) else interceptor_cls + ) def make_interceptor_next(nxt, icpt=interceptor): return lambda: icpt.intercept(context, nxt) diff --git a/tests/test_spillover.py b/tests/test_spillover.py new file mode 100644 index 0000000..4fc0846 --- /dev/null +++ b/tests/test_spillover.py @@ -0,0 +1,388 @@ +"""Issue #67: bounded results, caller isolation, and safe spillover stores.""" + +import asyncio +import gc +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import mcp.types as types +import pytest +from pydantic import BaseModel + +from nitrostack import ( + AuthContext, DataSpilloverInterceptor, DIContainer, ExecutionContext, + FileSpilloverStore, MemorySpilloverStore, PayloadTooLargeError, + ResourceNotFoundError, generate_preview, module, tool, use_interceptors, +) +from nitrostack.core.app import McpApplication +from nitrostack.core.interceptors.data_spillover import ( + SpilloverRegistry, SpilloverResult, caller_owner, +) +from nitrostack.protocol.mrtr import InputRequiredResult +from nitrostack.runtime.request_ctx import RequestContext, request_ctx + + +@pytest.fixture(autouse=True) +def reset_container(): + DIContainer.reset() + yield + DIContainer.reset() + + +@pytest.fixture(params=["memory", "file"]) +def store(request, tmp_path): + cls = MemorySpilloverStore if request.param == "memory" else FileSpilloverStore + args = () if request.param == "memory" else (tmp_path / "results",) + return cls(*args, max_bytes=4096, max_total_bytes=16384, ttl_seconds=10) + + +def test_store_roundtrip_owner_delete(store): + async def scenario(): + key = await store.put(b'{"answer":42}', owner="alice", mime_type="application/json") + assert await store.get(key, owner="bob") is None + assert not await store.delete(key, owner="bob") + entry = await store.get(key, owner="alice") + assert entry.payload == b'{"answer":42}' + assert entry.mime_type == "application/json" + assert await store.delete(key, owner="alice") + assert await store.get(key, owner="alice") is None + asyncio.run(scenario()) + + +def test_store_expiry_and_payload_limit(store, monkeypatch): + clock = [1000.0] + monkeypatch.setattr("nitrostack.core.interceptors.spillover.time.time", lambda: clock[0]) + async def scenario(): + key = await store.put(b"hello", owner="alice", mime_type="text/plain") + with pytest.raises(PayloadTooLargeError): + await store.put(b"x" * 4097, owner="alice", mime_type="text/plain") + clock[0] += 10 + assert await store.cleanup_expired() == 1 + assert await store.get(key, owner="alice") is None + asyncio.run(scenario()) + + +def test_memory_lru_and_total_capacity(): + store = MemorySpilloverStore(max_bytes=8, max_total_bytes=8) + async def scenario(): + first = await store.put(b"1111", owner="a", mime_type="text/plain") + second = await store.put(b"2222", owner="a", mime_type="text/plain") + await store.get(first, owner="a") + await store.put(b"3333", owner="a", mime_type="text/plain") + assert await store.get(second, owner="a") is None + assert await store.get(first, owner="a") is not None + assert store._size == 8 + asyncio.run(scenario()) + + +def test_file_capacity_restart_cleanup(tmp_path, monkeypatch): + clock = [1000.0] + monkeypatch.setattr("nitrostack.core.interceptors.spillover.time.time", lambda: clock[0]) + store = FileSpilloverStore(tmp_path, max_total_bytes=300, ttl_seconds=5) + async def scenario(): + key = await store.put(b"x" * 80, owner="a", mime_type="text/plain") + with pytest.raises(ValueError, match="capacity"): + await store.put(b"x" * 80, owner="a", mime_type="text/plain") + reopened = FileSpilloverStore(tmp_path, max_total_bytes=300) + assert (await reopened.get(key, owner="a")).payload == b"x" * 80 + assert list(tmp_path.glob("*.tmp")) == [] + clock[0] += 5 + FileSpilloverStore(tmp_path) + assert list(tmp_path.glob("*.json")) == [] + asyncio.run(scenario()) + + +@pytest.mark.parametrize("key", ["../escape", "..\\escape", "%2e%2e%2fescape", "/etc/passwd", "C:\\escape", "x?y"]) +def test_file_rejects_unsafe_ids(tmp_path, key): + store = FileSpilloverStore(tmp_path) + async def scenario(): + with pytest.raises(ValueError): + await store.get(key, owner="a") + with pytest.raises(ValueError): + await store.delete(key, owner="a") + asyncio.run(scenario()) + + +def test_file_rejects_symlink_escape(tmp_path): + root = tmp_path / "results" + store = FileSpilloverStore(root) + outside = tmp_path / "private.json" + outside.write_text("private") + try: + (root / ("a" * 32 + ".json")).symlink_to(outside) + except OSError: + pytest.skip("Creating symlinks requires OS privileges") + with pytest.raises(ValueError, match="escapes"): + asyncio.run(store.get("a" * 32, owner="a")) + assert outside.read_text() == "private" + + +def test_concurrent_store_operations(store): + async def scenario(): + keys = await asyncio.gather(*[ + store.put(str(i).encode(), owner="a", mime_type="text/plain") for i in range(20) + ]) + assert len(set(keys)) == 20 + entries = await asyncio.gather(*[store.get(key, owner="a") for key in keys]) + assert [entry.payload for entry in entries] == [str(i).encode() for i in range(20)] + asyncio.run(scenario()) + + +@pytest.mark.parametrize("value", [list(range(20)), {str(i): i for i in range(20)}, "é" * 10000, + [{"huge": "x" * 10000}], {"huge": ["x" * 10000]}], + ids=["list", "dict", "unicode", "nested-list", "nested-dict"]) +def test_previews_are_bounded(value): + preview = generate_preview(value) + assert preview["count"] == len(value) + assert preview["truncated"] + assert len(json.dumps(preview, ensure_ascii=False).encode()) <= 2048 + + +def test_threshold_boundary_and_full_payload(store): + interceptor = DataSpilloverInterceptor(threshold_bytes=4, store=store) + context = ExecutionContext(request_id="1", auth=AuthContext(subject="alice"), logger=MagicMock()) + async def scenario(): + for value in ["éé", {"a": 1}, [1, 2]]: + async def handler(): + return value + result = await interceptor.intercept(context, handler) + if value == "éé": + assert result is value + else: + assert isinstance(result, SpilloverResult) + entry = await store.get(result.resource_uri.rsplit("/", 1)[1], owner=caller_owner(context)) + assert json.loads(entry.payload) == value + assert result.original_size_bytes == len(entry.payload) + async def unicode_handler(): + return "ééé" + assert (await interceptor.intercept(context, unicode_handler)).original_size_bytes == 6 + asyncio.run(scenario()) + + +def test_control_results_and_missing_identity(): + interceptor = DataSpilloverInterceptor(threshold_bytes=1) + context = ExecutionContext(request_id="1", logger=MagicMock()) + async def scenario(): + controls = [types.CallToolResult(content=[], isError=True), + {"resultType": "input_required", "message": "more"}, + InputRequiredResult(input_requests=[], request_state={}, message="more")] + for value in controls: + async def handler(): + return value + assert await interceptor.intercept(context, handler) is value + async def large(): + return "large" + with pytest.raises(PermissionError): + await interceptor.intercept(context, large) + asyncio.run(scenario()) + + +class Session: + pass + + +class ToolInput(BaseModel): + text: str = "large result" + + +def make_app(interceptor, *, task_support="forbidden", output_schema=None): + class Tools: + @tool(name="large", description="Test spillover", input_schema=ToolInput, + task_support=task_support, output_schema=output_schema) + @use_interceptors(interceptor) + async def large(self, input, context): + return {"text": input.text} + @module(name="spillover-test", providers=[Tools]) + class Root: + pass + return McpApplication(Root) + + +async def call(app, rc, text="large result", task=None): + token = request_ctx.set(rc) + try: + if task is not None: + return await app._call_tool("large", {"text": text}, task=task) + handler = app.mcp_server.request_handlers[types.CallToolRequest] + return (await handler(types.CallToolRequest( + method="tools/call", params=types.CallToolRequestParams(name="large", arguments={"text": text}), + ))).root + finally: + request_ctx.reset(token) + + +async def read(app, rc, uri): + token = request_ctx.set(rc) + try: + handler = app.mcp_server.request_handlers[types.ReadResourceRequest] + return (await handler(types.ReadResourceRequest( + method="resources/read", params=types.ReadResourceRequestParams(uri=uri), + ))).root.contents[0].text + finally: + request_ctx.reset(token) + + +def test_mcp_resource_roundtrip_and_session_isolation(store): + app = make_app(DataSpilloverInterceptor(threshold_bytes=1, store=store)) + alice = RequestContext(session=Session()) + bob = RequestContext(session=Session()) + async def scenario(): + result = await call(app, alice) + assert not result.is_error + data = result.structured_content + assert result.content[1].type == "resource_link" + assert json.loads(await read(app, alice, data["resource_uri"])) == {"text": "large result"} + for caller in [bob, None]: + with pytest.raises(ResourceNotFoundError): + await read(app, caller, data["resource_uri"]) + with pytest.raises(ResourceNotFoundError): + await read(app, alice, "resource://data-spillover/../escape") + assert data["original_size_bytes"] == len(b'{"text":"large result"}') + asyncio.run(scenario()) + + +def test_principal_tenant_issuer_isolation(monkeypatch): + payloads = { + "alice": {"sub": "alice", "iss": "issuer", "tenant_id": "one"}, + "bob": {"sub": "bob", "iss": "issuer", "tenant_id": "one"}, + "tenant": {"sub": "alice", "iss": "issuer", "tenant_id": "two"}, + "issuer": {"sub": "alice", "iss": "other", "tenant_id": "one"}, + } + monkeypatch.setattr("nitrostack.auth.request.verify_bearer_payload", payloads.get) + app = make_app(DataSpilloverInterceptor(threshold_bytes=1)) + def rc(token): + return RequestContext(request=SimpleNamespace(headers={"authorization": "Bearer " + token}), + session=Session(), meta={"userId": "alice", "tenantId": "one"}) + async def scenario(): + result = await call(app, rc("alice")) + uri = result.structured_content["resource_uri"] + assert json.loads(await read(app, rc("alice"), uri)) == {"text": "large result"} + for other in ["bob", "tenant", "issuer", "invalid"]: + with pytest.raises(ResourceNotFoundError): + await read(app, rc(other), uri) + assert (await call(app, rc("invalid"))).is_error + asyncio.run(scenario()) + + +def test_class_decorator_custom_prefix_and_schema(): + class Configured(DataSpilloverInterceptor): + def __init__(self): + super().__init__(threshold_bytes=1, resource_uri_prefix="resource://custom/") + schema = {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]} + app = make_app(Configured, output_schema=schema) + async def scenario(): + rc = RequestContext(session=Session()) + result = await call(app, rc) + assert result.structured_content["resource_uri"].startswith("resource://custom/") + definition = app._build_tool_definition(app._tools["large"]) + original = definition.output_schema["anyOf"][0] + assert all(original[key] == value for key, value in schema.items()) + assert definition.output_schema["anyOf"][1]["required"] == ["preview", "resource_uri", "original_size_bytes"] + asyncio.run(scenario()) + + +def test_registry_conflicts_and_session_cleanup(): + registry = SpilloverRegistry() + interceptor = DataSpilloverInterceptor() + registry.register(interceptor) + registry.register(interceptor) + with pytest.raises(ValueError, match="Conflicting"): + registry.register(DataSpilloverInterceptor()) + rc = RequestContext(session=Session()) + assert registry.session_key(rc) == registry.session_key(rc) + del rc + gc.collect() + assert registry._sessions == {} + + +def test_mcp_expiry_and_store_errors(store, monkeypatch): + clock = [1000.0] + monkeypatch.setattr("nitrostack.core.interceptors.spillover.time.time", lambda: clock[0]) + app = make_app(DataSpilloverInterceptor(threshold_bytes=1, store=store)) + rc = RequestContext(session=Session()) + async def scenario(): + result = await call(app, rc) + uri = result.structured_content["resource_uri"] + clock[0] += 10 + with pytest.raises(ResourceNotFoundError): + await read(app, rc, uri) + oversized = await call(app, rc, text="x" * 5000) + assert oversized.is_error + assert "max_bytes" in oversized.content[0].text + assert "x" * 5000 not in oversized.content[0].text + asyncio.run(scenario()) + + +def test_models_mcp_results_and_widget_bypass(store): + interceptor = DataSpilloverInterceptor(threshold_bytes=1, store=store) + context = ExecutionContext(request_id="1", auth=AuthContext(subject="alice"), logger=MagicMock()) + app = make_app(interceptor) + async def scenario(): + for value in [ToolInput(text="full"), types.CallToolResult( + content=[types.TextContent(type="text", text="full")], structuredContent={"text": "full"}, + )]: + async def handler(): + return value + spilled = await interceptor.intercept(context, handler) + payload = await store.get(spilled.resource_uri.rsplit("/", 1)[1], owner=caller_owner(context)) + assert json.loads(payload.payload) == value.model_dump(mode="json", by_alias=True, exclude_none=True) + component = MagicMock() + result = app._to_call_tool_result(spilled, component, context) + assert result.structured_content["resource_uri"] == spilled.resource_uri + component.transformer.assert_not_called() + asyncio.run(scenario()) + + +def test_background_task_spills_and_can_be_read(): + app = make_app(DataSpilloverInterceptor(threshold_bytes=1), task_support="optional") + rc = RequestContext(session=Session()) + async def scenario(): + result = await call(app, rc, task=types.TaskMetadata(ttl=60000)) + for _ in range(100): + entry = await app.task_manager._store.get(result.task.task_id) + if entry.result is not None: + break + await asyncio.sleep(0.01) + assert entry.result is not None + stored = entry.result + data = stored.structured_content if isinstance(stored, types.CallToolResult) else stored["structuredContent"] + assert json.loads(await read(app, rc, data["resource_uri"])) == {"text": "large result"} + asyncio.run(scenario()) + + +def test_opaque_oauth_identity_reads_resources(monkeypatch): + from nitrostack.auth.oauth import OAuthService + from unittest.mock import AsyncMock + + monkeypatch.setattr("nitrostack.auth.request.verify_bearer_payload", lambda token: None) + service = MagicMock() + service.introspect_token = AsyncMock(return_value={"active": True, "sub": "alice", "iss": "oauth"}) + DIContainer.get_instance().register_value(OAuthService, service) + app = make_app(DataSpilloverInterceptor(threshold_bytes=1)) + rc = RequestContext(request=SimpleNamespace(headers={"authorization": "Bearer opaque"})) + async def scenario(): + result = await call(app, rc) + uri = result.structured_content["resource_uri"] + assert json.loads(await read(app, rc, uri)) == {"text": "large result"} + service.introspect_token.return_value = {"active": False} + with pytest.raises(ResourceNotFoundError): + await read(app, rc, uri) + asyncio.run(scenario()) + + +def test_file_symlink_check_without_os_privileges(tmp_path, monkeypatch): + from pathlib import Path + + store = FileSpilloverStore(tmp_path) + monkeypatch.setattr(Path, "is_symlink", lambda path: path.name == "a" * 32 + ".json") + with pytest.raises(ValueError, match="escapes"): + asyncio.run(store.get("a" * 32, owner="a")) + + +@pytest.mark.parametrize("kwargs", [{"threshold_bytes": -1}, {"threshold_bytes": 0.5}, + {"resource_uri_prefix": "../results/"}, + {"resource_uri_prefix": "resource://results/?x/"}]) +def test_invalid_interceptor_configuration(kwargs): + with pytest.raises(ValueError): + DataSpilloverInterceptor(**kwargs)