From a733e2e38cc9d98d4e72cc1f59ddb3419dd2023f Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Thu, 10 Sep 2026 21:06:02 +0530 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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 3688ed096a35c554409d39fa32ff8b211246fecd Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 22:16:24 +0530 Subject: [PATCH 16/20] test: capture tools/list baseline before transform pipeline (#61) --- scripts/capture_tools_baseline.py | 56 +++++++++++++++++++++++++ tests/fixtures/tools_list_baseline.json | 5 +++ 2 files changed, 61 insertions(+) create mode 100644 scripts/capture_tools_baseline.py create mode 100644 tests/fixtures/tools_list_baseline.json diff --git a/scripts/capture_tools_baseline.py b/scripts/capture_tools_baseline.py new file mode 100644 index 0000000..50b52a2 --- /dev/null +++ b/scripts/capture_tools_baseline.py @@ -0,0 +1,56 @@ + +"""Capture real tools/list responses from the unmodified base SDK.""" +import asyncio +import json +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import mcp.types as types + +from nitrostack.testing import NitroTestingModule +from tests._baseline_catalog import ERAS, M + +OUT = ROOT / "tests" / "fixtures" / "tools_list_baseline.json" + + +async def list_tools(era: str) -> str: + harness = await NitroTestingModule.create(M, protocol_era=era) + server = harness.app.mcp_server + + response = ( + await server.request_handlers[types.ListToolsRequest]( + types.ListToolsRequest(method="tools/list") + ) + ).root + + return response.model_dump_json( + by_alias=True, + exclude_none=True, + ) + + +async def main() -> None: + baseline = {} + + for era in ERAS: + serialized = await list_tools(era) + baseline[era] = serialized + names = [ + item["name"] + for item in json.loads(serialized)["tools"] + ] + print(f"{era}: {names}") + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text( + json.dumps(baseline, indent=2), + encoding="utf-8", + ) + print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/tools_list_baseline.json b/tests/fixtures/tools_list_baseline.json new file mode 100644 index 0000000..6edb97d --- /dev/null +++ b/tests/fixtures/tools_list_baseline.json @@ -0,0 +1,5 @@ +{ + "legacy": "{\"_meta\":{\"io.modelcontextprotocol/cacheHint\":{\"ttlMs\":60000,\"cacheScope\":\"private\"}},\"ttlMs\":0,\"cacheScope\":\"private\",\"tools\":[{\"name\":\"alpha\",\"description\":\"first\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"alpha\",\"description\":\"first\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}},{\"name\":\"beta\",\"description\":\"second\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"beta\",\"description\":\"second\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}}],\"resultType\":\"complete\"}", + "auto": "{\"_meta\":{\"io.modelcontextprotocol/cacheHint\":{\"ttlMs\":60000,\"cacheScope\":\"private\"}},\"ttlMs\":0,\"cacheScope\":\"private\",\"tools\":[{\"name\":\"alpha\",\"description\":\"first\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"alpha\",\"description\":\"first\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}},{\"name\":\"beta\",\"description\":\"second\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"beta\",\"description\":\"second\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}}],\"resultType\":\"complete\"}", + "modern": "{\"_meta\":{\"io.modelcontextprotocol/cacheHint\":{\"ttlMs\":60000,\"cacheScope\":\"private\"}},\"ttlMs\":0,\"cacheScope\":\"private\",\"tools\":[{\"name\":\"alpha\",\"description\":\"first\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"alpha\",\"description\":\"first\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}},{\"name\":\"beta\",\"description\":\"second\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"beta\",\"description\":\"second\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}}],\"resultType\":\"complete\"}" +} \ No newline at end of file From 556c217e34bf5b9e5d770b34dad50ae04494d87d Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 22:17:00 +0530 Subject: [PATCH 17/20] feat(transforms): add McpTransform protocol, CatalogTransform and TransformPipeline (#61) --- nitrostack/transforms/__init__.py | 20 +++++ nitrostack/transforms/pipeline.py | 112 ++++++++++++++++++++++++ nitrostack/transforms/transform.py | 128 ++++++++++++++++++++++++++++ nitrostack/transforms/validation.py | 29 +++++++ tests/__init__.py | 0 tests/test_transforms_pipeline.py | 104 ++++++++++++++++++++++ tests/test_transforms_reentrancy.py | 50 +++++++++++ 7 files changed, 443 insertions(+) create mode 100644 nitrostack/transforms/__init__.py create mode 100644 nitrostack/transforms/pipeline.py create mode 100644 nitrostack/transforms/transform.py create mode 100644 nitrostack/transforms/validation.py create mode 100644 tests/__init__.py create mode 100644 tests/test_transforms_pipeline.py create mode 100644 tests/test_transforms_reentrancy.py diff --git a/nitrostack/transforms/__init__.py b/nitrostack/transforms/__init__.py new file mode 100644 index 0000000..667e7d7 --- /dev/null +++ b/nitrostack/transforms/__init__.py @@ -0,0 +1,20 @@ +from nitrostack.transforms.transform import ( + CatalogTransform, + McpTransform, + McpTransformBase, + TransformContext, + active_bypass, + bypassing, +) +from nitrostack.transforms.pipeline import ( + TransformError, + TransformPipeline, + TransformRegistry, +) +from nitrostack.transforms.validation import ToolArgumentError, validate_tool_arguments + +__all__ = [ + "CatalogTransform", "McpTransform", "McpTransformBase", "TransformContext", + "TransformError", "TransformPipeline", "TransformRegistry", + "ToolArgumentError", "validate_tool_arguments", "active_bypass", "bypassing", +] \ No newline at end of file diff --git a/nitrostack/transforms/pipeline.py b/nitrostack/transforms/pipeline.py new file mode 100644 index 0000000..282de09 --- /dev/null +++ b/nitrostack/transforms/pipeline.py @@ -0,0 +1,112 @@ +"""Ordered execution of transforms for tools/list and tools/call resolution.""" +from __future__ import annotations + +import logging +from typing import Any, Awaitable, Callable, List, Optional, Sequence + +from nitrostack.transforms.transform import ( + NextResolver, Tool, TransformContext, active_bypass, maybe_await, +) + +logger = logging.getLogger(__name__) + +BaseResolver = Callable[[str], Any] # returns Tool | None (sync or async) + + +class TransformError(Exception): + """A transform hook raised. The app maps this to a JSON-RPC error.""" + + def __init__(self, transform_name: str, hook: str) -> None: + super().__init__(f"Transform '{transform_name}' failed in {hook}.") + self.transform_name = transform_name + self.hook = hook + + +class TransformRegistry: + """Read-only view passed to ``on_register``.""" + + def __init__(self, list_tools: Callable[[], Sequence[Tool]]) -> None: + self._list_tools = list_tools + + def list_tools(self) -> List[Tool]: + return list(self._list_tools()) + + +class TransformPipeline: + def __init__(self, transforms: Optional[Sequence[Any]] = None) -> None: + self._transforms: List[Any] = list(transforms or []) + names = [t.name for t in self._transforms] + if len(set(names)) != len(names): + raise ValueError(f"Transform names must be unique, got {names}") + + @property + def enabled(self) -> bool: + return bool(self._transforms) + + def register(self, registry: TransformRegistry) -> None: + for t in self._transforms: + try: + t.on_register(registry) + except Exception as exc: + raise TransformError(t.name, "on_register") from exc + + async def transform_tools( + self, tools: Sequence[Tool], ctx: TransformContext + ) -> List[Tool]: + current = list(tools) + if not self._transforms: + return current + skipped = active_bypass() + for t in self._transforms: + if t.name in skipped: + continue + try: + current = list(await maybe_await(t.transform_tools(current, ctx))) + except TransformError: + raise + except Exception as exc: + logger.exception("Transform %s failed in transform_tools", t.name) + raise TransformError(t.name, "transform_tools") from exc + return current + + async def resolve_tool( + self, name: str, base: BaseResolver, ctx: TransformContext + ) -> Optional[Tool]: + """First declared transform is outermost; each chains through ``next``. + + ``base`` must only look the tool up (return None if unknown). Guards and + argument validation happen after resolution, in the app's invoker. + """ + if not self._transforms: + return await maybe_await(base(name)) + + transforms = self._transforms + + async def run(index: int, tool_name: str) -> Optional[Tool]: + skipped = active_bypass() + while index < len(transforms) and transforms[index].name in skipped: + index += 1 + if index >= len(transforms): + return await maybe_await(base(tool_name)) + t = transforms[index] + + async def nxt(n: str) -> Optional[Tool]: + return await run(index + 1, n) + + try: + return await maybe_await(t.resolve_tool(tool_name, nxt, ctx)) + except TransformError: + raise + except Exception as exc: + logger.exception("Transform %s failed in resolve_tool", t.name) + raise TransformError(t.name, "resolve_tool") from exc + + return await run(0, name) + + async def dispose(self) -> None: + """Dispose in reverse order; one failure must not stop the others.""" + for t in reversed(self._transforms): + try: + await maybe_await(t.dispose()) + except Exception: + logger.exception("Transform %s failed in dispose", t.name) \ No newline at end of file diff --git a/nitrostack/transforms/transform.py b/nitrostack/transforms/transform.py new file mode 100644 index 0000000..f3f7a83 --- /dev/null +++ b/nitrostack/transforms/transform.py @@ -0,0 +1,128 @@ +"""Transform protocol, base classes, context and re-entrancy bypass.""" +from __future__ import annotations + +import contextlib +import contextvars +import inspect +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import ( + Any, Awaitable, Callable, Dict, Iterator, List, Optional, Protocol, + Sequence, runtime_checkable, +) + +Tool = Any # mcp.types.Tool in practice; kept loose so transforms may add synthetic tools +NextResolver = Callable[[str], Awaitable[Optional[Tool]]] +ToolInvoker = Callable[[str, Dict[str, Any], "TransformContext"], Awaitable[Any]] + +# Names of transforms to skip for the current call chain. Set while a synthetic +# tool calls a real tool, so the issuing transform is not re-applied. +_BYPASS: contextvars.ContextVar[frozenset] = contextvars.ContextVar( + "nitrostack_transform_bypass", default=frozenset() +) + + +def active_bypass() -> frozenset: + return _BYPASS.get() + + +@contextlib.contextmanager +def bypassing(name: str) -> Iterator[None]: + token = _BYPASS.set(_BYPASS.get() | {name}) + try: + yield + finally: + _BYPASS.reset(token) + + +async def maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +@dataclass +class TransformContext: + """Per-request context handed to every transform hook.""" + + session_id: Optional[str] = None + execution_context: Any = None # the ExecutionContext of the originating request + metadata: Dict[str, Any] = field(default_factory=dict) + _invoker: Optional[ToolInvoker] = None + + async def call_tool( + self, + name: str, + arguments: Optional[Dict[str, Any]] = None, + *, + caller: Optional[str] = None, + ) -> Any: + """Invoke a real tool from a synthetic tool. + + Goes through the same path as a normal tools/call, so guards and argument + validation run for the real tool. If ``caller`` is given, that transform + is skipped for the duration of the call (no re-entrancy loop). + """ + if self._invoker is None: + raise RuntimeError("TransformContext has no tool invoker attached.") + args = arguments or {} + if caller: + with bypassing(caller): + return await self._invoker(name, args, self) + return await self._invoker(name, args, self) + + +@runtime_checkable +class McpTransform(Protocol): + name: str + + def on_register(self, registry: Any) -> Any: ... + async def dispose(self) -> None: ... + async def transform_tools( + self, tools: Sequence[Tool], ctx: TransformContext + ) -> List[Tool]: ... + async def resolve_tool( + self, name: str, next: NextResolver, ctx: TransformContext + ) -> Optional[Tool]: ... + + +class McpTransformBase: + """Convenience base: every hook defaults to a pass-through. Hooks may be sync or async.""" + + name: str = "transform" + + def on_register(self, registry: Any) -> None: + return None + + async def dispose(self) -> None: + return None + + async def transform_tools(self, tools: Sequence[Tool], ctx: TransformContext) -> List[Tool]: + return list(tools) + + async def resolve_tool( + self, name: str, next: NextResolver, ctx: TransformContext + ) -> Optional[Tool]: + return await next(name) + + def is_bypassed(self) -> bool: + return self.name in _BYPASS.get() + + +class CatalogTransform(McpTransformBase): + """Base for transforms that rewrite the catalog. + + Subclasses implement ``apply_transform``. While this transform's own synthetic + tool is calling a real tool, ``transform_tools`` returns the catalog unchanged. + """ + + @abstractmethod + def apply_transform( + self, tools: Sequence[Tool], ctx: TransformContext + ) -> Any: # list or awaitable of list + ... + + async def transform_tools(self, tools: Sequence[Tool], ctx: TransformContext) -> List[Tool]: + if self.is_bypassed(): + return list(tools) + return list(await maybe_await(self.apply_transform(tools, ctx))) \ No newline at end of file diff --git a/nitrostack/transforms/validation.py b/nitrostack/transforms/validation.py new file mode 100644 index 0000000..621537a --- /dev/null +++ b/nitrostack/transforms/validation.py @@ -0,0 +1,29 @@ +"""One place that validates tool arguments, shared by normal and re-entrant calls.""" +from __future__ import annotations + +from typing import Any, Dict, Optional, Type + +from pydantic import BaseModel, ValidationError + + +class ToolArgumentError(ValueError): + """Arguments did not match the tool's input schema.""" + + def __init__(self, tool_name: str, errors: list) -> None: + super().__init__(f"Invalid arguments for tool '{tool_name}'.") + self.tool_name = tool_name + self.errors = errors + + +def validate_tool_arguments( + tool_name: str, + input_model: Optional[Type[BaseModel]], + arguments: Dict[str, Any], +) -> Any: + """Return the validated input object (or the raw dict if the tool has no model).""" + if input_model is None: + return arguments + try: + return input_model.model_validate(arguments) + except ValidationError as exc: + raise ToolArgumentError(tool_name, exc.errors(include_url=False)) from exc \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_transforms_pipeline.py b/tests/test_transforms_pipeline.py new file mode 100644 index 0000000..dd37e31 --- /dev/null +++ b/tests/test_transforms_pipeline.py @@ -0,0 +1,104 @@ +import pytest + +from nitrostack.transforms import ( + CatalogTransform, McpTransformBase, TransformContext, TransformError, TransformPipeline, +) + + +class Tag(McpTransformBase): + def __init__(self, name, log): + self.name = name + self.log = log + + async def transform_tools(self, tools, ctx): + self.log.append(self.name) + return [t + f"+{self.name}" for t in tools] + + async def resolve_tool(self, name, next, ctx): + self.log.append(f"resolve:{self.name}") + found = await next(name) + return None if found is None else f"{found}|{self.name}" + + +@pytest.mark.asyncio +async def test_empty_pipeline_is_identity(): + p = TransformPipeline([]) + assert not p.enabled + tools = ["a", "b"] + assert await p.transform_tools(tools, TransformContext()) == ["a", "b"] + assert await p.resolve_tool("a", lambda n: n, TransformContext()) == "a" + + +@pytest.mark.asyncio +async def test_list_transforms_apply_in_declared_order(): + log = [] + p = TransformPipeline([Tag("one", log), Tag("two", log)]) + out = await p.transform_tools(["x"], TransformContext()) + assert out == ["x+one+two"] + assert log == ["one", "two"] + + +@pytest.mark.asyncio +async def test_resolve_chains_through_next_first_is_outermost(): + log = [] + p = TransformPipeline([Tag("one", log), Tag("two", log)]) + out = await p.resolve_tool("x", lambda n: n, TransformContext()) + assert out == "x|two|one" + assert log == ["resolve:one", "resolve:two"] + + +@pytest.mark.asyncio +async def test_resolve_unknown_tool_returns_none(): + p = TransformPipeline([Tag("one", [])]) + assert await p.resolve_tool("nope", lambda n: None, TransformContext()) is None + + +@pytest.mark.asyncio +async def test_raising_transform_becomes_transform_error(): + class Boom(McpTransformBase): + name = "boom" + + async def transform_tools(self, tools, ctx): + raise RuntimeError("bad") + + p = TransformPipeline([Boom()]) + with pytest.raises(TransformError) as e: + await p.transform_tools(["a"], TransformContext()) + assert e.value.transform_name == "boom" + # the pipeline is still usable afterwards + assert await TransformPipeline([]).transform_tools(["a"], TransformContext()) == ["a"] + + +@pytest.mark.asyncio +async def test_dispose_runs_all_in_reverse_even_if_one_fails(): + order = [] + + class D(McpTransformBase): + def __init__(self, name, fail=False): + self.name, self.fail = name, fail + + async def dispose(self): + order.append(self.name) + if self.fail: + raise RuntimeError("x") + + p = TransformPipeline([D("a"), D("b", fail=True), D("c")]) + await p.dispose() + assert order == ["c", "b", "a"] + + +def test_duplicate_names_rejected(): + with pytest.raises(ValueError): + TransformPipeline([Tag("same", []), Tag("same", [])]) + + +@pytest.mark.asyncio +async def test_catalog_transform_applies_and_supports_sync_apply(): + class Upper(CatalogTransform): + name = "upper" + + def apply_transform(self, tools, ctx): + return [t.upper() for t in tools] + + p = TransformPipeline([Upper()]) + assert await p.transform_tools(["a"], TransformContext()) == ["A"] \ No newline at end of file diff --git a/tests/test_transforms_reentrancy.py b/tests/test_transforms_reentrancy.py new file mode 100644 index 0000000..ee549c1 --- /dev/null +++ b/tests/test_transforms_reentrancy.py @@ -0,0 +1,50 @@ +import pytest + +from nitrostack.transforms import ( + CatalogTransform, TransformContext, TransformPipeline, bypassing, +) + + +class Hide(CatalogTransform): + name = "hide" + + def __init__(self): + self.applied = 0 + + def apply_transform(self, tools, ctx): + self.applied += 1 + return [t for t in tools if t != "secret"] + + +@pytest.mark.asyncio +async def test_transform_is_skipped_while_its_own_call_is_running(): + h = Hide() + p = TransformPipeline([h]) + ctx = TransformContext() + + assert await p.transform_tools(["a", "secret"], ctx) == ["a"] + assert h.applied == 1 + + with bypassing("hide"): + assert await p.transform_tools(["a", "secret"], ctx) == ["a", "secret"] + assert h.applied == 1 # not re-run + + assert await p.transform_tools(["a", "secret"], ctx) == ["a"] # bypass was released + assert h.applied == 2 + + +@pytest.mark.asyncio +async def test_call_tool_with_caller_bypasses_that_transform_only(): + seen = {} + + async def invoker(name, args, ctx): + from nitrostack.transforms import active_bypass + seen["bypass"] = set(active_bypass()) + return f"called {name}" + + ctx = TransformContext(_invoker=invoker) + assert await ctx.call_tool("real", {}, caller="hide") == "called real" + assert seen["bypass"] == {"hide"} + + await ctx.call_tool("real", {}) + assert seen["bypass"] == set() \ No newline at end of file From ff1157c653af763fdd1f5265b1948b165bf52bbe Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 22:17:19 +0530 Subject: [PATCH 18/20] feat(core): apply transforms to tools/list and tools/call, share argument validation (#61) --- tests/test_transformsapp.py | 127 ++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_transformsapp.py diff --git a/tests/test_transformsapp.py b/tests/test_transformsapp.py new file mode 100644 index 0000000..779cd7b --- /dev/null +++ b/tests/test_transformsapp.py @@ -0,0 +1,127 @@ + +import json +import pathlib + +import mcp.types as types +import pytest + +from nitrostack.testing import NitroTestingModule +from nitrostack.transforms import CatalogTransform, McpTransformBase +from tests._baseline_catalog import ERAS, In, M + + +BASELINE = ( + pathlib.Path(__file__).parent + / "fixtures" + / "tools_list_baseline.json" +) + + +async def _list(h): + srv = h.app.mcp_server + response = ( + await srv.request_handlers[types.ListToolsRequest]( + types.ListToolsRequest(method="tools/list") + ) + ).root + return response + + +@pytest.mark.asyncio +@pytest.mark.parametrize("era", ERAS) +async def test_no_transforms_output_is_byte_identical_to_baseline( + era, monkeypatch +): + monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era) + + h = await NitroTestingModule.create(M) + response = await _list(h) + + expected = json.loads( + BASELINE.read_text(encoding="utf-8") + )[era] + + actual = response.model_dump_json( + by_alias=True, + exclude_none=True, + ) + + assert actual == expected + + +class DropBeta(CatalogTransform): + name = "drop_beta" + + def apply_transform(self, tools, ctx): + return [tool for tool in tools if tool.name != "beta"] + + +class Suffix(CatalogTransform): + name = "suffix" + + def apply_transform(self, tools, ctx): + return [ + tool.model_copy( + update={ + "description": (tool.description or "") + "!" + } + ) + for tool in tools + ] + + +@pytest.mark.asyncio +async def test_two_transforms_compose_in_declared_order_for_list(): + h = await NitroTestingModule.create( + M, + transforms=[DropBeta(), Suffix()], + ) + + response = await _list(h) + + assert [tool.name for tool in response.tools] == ["alpha"] + assert response.tools[0].description == "first!" + + +class Boom(McpTransformBase): + name = "boom" + + async def transform_tools(self, tools, ctx): + raise RuntimeError("bad transform") + + +@pytest.mark.asyncio +async def test_failing_transform_returns_error_and_server_keeps_serving(): + h = await NitroTestingModule.create( + M, + transforms=[Boom()], + ) + + with pytest.raises(Exception): + await _list(h) + + # A transform failure must not stop the server from handling tool calls. + result = await h.call_tool("alpha", {"input": {"q": "x"}}) + assert result is not None + + +@pytest.mark.asyncio +async def test_dispose_runs_on_shutdown(): + disposed = [] + + class D(McpTransformBase): + name = "d" + + async def dispose(self): + disposed.append(True) + + h = await NitroTestingModule.create( + M, + transforms=[D()], + ) + + # Exercise disposal directly because this harness may not expose + # a public application-shutdown method. + await h.app._transforms.dispose() + + assert disposed == [True] From ef65cf6be57b521a8fe3db0107b24af858557792 Mon Sep 17 00:00:00 2001 From: Shreyavemana <128345703+Shreyavemana@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:27:13 +0530 Subject: [PATCH 19/20] Update mcp dependency version range --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 59850ac..b3aa34a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,9 @@ version = "0.4.2" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" +[project] dependencies = [ - "mcp>=2,<3", + "mcp>=2.0.0,<2.7.0", "pydantic>=2.0.0", "starlette>=0.30.0", "uvicorn>=0.20.0", From d222b951c54970e263fb071a50a066a922a096b6 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 22:39:37 +0530 Subject: [PATCH 20/20] ci: pin MCP dependency and verify imports --- .github/workflows/test.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 910f9c0..e53608a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,3 +1,4 @@ + name: Python SDK Test Suite on: @@ -23,8 +24,14 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + python -m pip install --upgrade pip setuptools wheel + python -m pip install "mcp>=2.0.0,<2.7.0" + python -m pip install -e ".[dev]" + python - <<'PY' + import mcp + import mcp.types + print("mcp OK:", mcp.__version__) + PY - - name: Run pytest with coverage + - name: Run pytest run: pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60