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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ jobs:
pip install -e ".[dev]"

- name: Run pytest with coverage
run: pytest tests --cov=nitrostack --cov-report=term-missing
run: pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ flight-book-app/
my-pizza-app/
my-test-server/
new-app/
my-app/
y-server/

# Vendored SDK copies from `nitrostack-py init` (user projects only)
vendor/

# Local preview captures
.landing-preview.png
*-landing-preview.png

# Node widgets (if installed locally in examples/templates)
node_modules/
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
recursive-include nitrostack/templates *
recursive-include nitrostack/cli/templates *
recursive-include nitrostack/transports/assets *
include nitrostack/templates/starter/.python-version
include nitrostack/templates/starter/.gitignore
include nitrostack/templates/starter/uv.toml
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ 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.

---
Expand Down Expand Up @@ -260,6 +262,7 @@ NitroStack apps can run over three transports, selected via `MCP_TRANSPORT_TYPE`

- **`stdio`** (default outside production): JSON-RPC over stdin/stdout — the standard mode for desktop MCP clients (Claude Desktop, Cursor, etc.).
- **`http`**: Streamable HTTP + legacy SSE over a real network port, for cloud/remote deployments. Exposes:
- `GET /` — documentation landing page (connection setup for Cursor/Claude/ChatGPT plus the registered tool catalog). Same page TypeScript NitroStack serves on Open URL.
- `POST/GET/DELETE /mcp` — Streamable HTTP (session-based JSON-RPC + SSE streaming). `/mcp` and `/mcp/` are equivalent; the server does not 307 between them (MCP Inspector needs the no-slash URL for its SSE GET).
- `GET /sse` + `POST /mcp/messages/` — legacy HTTP+SSE for older clients (trailing slash required so messages aren't swallowed by the Streamable HTTP `/mcp` mount)
- `GET /mcp/health` — health check (`status`, active session count, uptime)
Expand Down
Binary file not shown.
Binary file removed nitrostack/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/auth/__pycache__/api_key.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/auth/__pycache__/config.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/auth/__pycache__/jwt.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/auth/__pycache__/oauth.cpython-312.pyc
Binary file not shown.
9 changes: 8 additions & 1 deletion nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,8 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
name=os.path.basename(os.path.abspath(name)),
description=description,
)
from nitrostack.cli.vendor import ensure_vendored_nitrostack
ensure_vendored_nitrostack(name)
lock_with_uv(name)
widget_routes = ensure_python_widgets(name)
print("\n\033[32m✓\033[0m Project created")
Expand All @@ -1313,14 +1315,19 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
lines = f.readlines()
new_lines = []
for line in lines:
if line.startswith("SERVER_DESC="):
if line.startswith("SERVER_NAME="):
new_lines.append(f'SERVER_NAME="{os.path.basename(os.path.abspath(name))}"\n')
elif line.startswith("SERVER_DESC="):
new_lines.append(f'SERVER_DESC="{description}"\n')
elif line.startswith("SERVER_AUTHOR="):
new_lines.append(f'SERVER_AUTHOR="{author}"\n')
else:
new_lines.append(line)
has_name = any(line.startswith("SERVER_NAME=") for line in new_lines)
has_desc = any(line.startswith("SERVER_DESC=") for line in new_lines)
has_author = any(line.startswith("SERVER_AUTHOR=") for line in new_lines)
if not has_name:
new_lines.append(f'SERVER_NAME="{os.path.basename(os.path.abspath(name))}"\n')
if not has_desc:
new_lines.append(f'SERVER_DESC="{description}"\n')
if not has_author:
Expand Down
2 changes: 2 additions & 0 deletions nitrostack/cli/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ def pack_project(
Returns a dict with ``files``, ``wheel`` (path or planned name), and ``dry_run``.
"""
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)
name, version = _project_name_and_version(root)
dist_name = _pep503_wheel_name(_normalize_dist_name(name))
Expand Down
216 changes: 216 additions & 0 deletions nitrostack/cli/vendor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Vendor the running NitroStack SDK into a scaffolded project.

Cloud deploy zips the project (not the developer's .venv) and `uv sync`s
from `pyproject.toml`. Putting a PEP 508 `file:` URL in `[project].dependencies`
breaks NitroCloud's Python uv builder (`Failed to parse metadata from built
wheel`). Keep the dependency name as `nitrostack` and point uv at the copy
with `[tool.uv.sources]`.
"""
from __future__ import annotations

import os
import re
import shutil
from pathlib import Path
from typing import Optional

from nitrostack.cli._shared import write_text_atomic

VENDOR_REL = os.path.join("vendor", "nitrostack")
_PLAIN_REQUIREMENT = "nitrostack"
_PATH_PINS = frozenset({"./vendor/nitrostack", "-e ./vendor/nitrostack"})
_NAMED_REQUIREMENT = re.compile(
r"^(?:-e\s+|--editable\s+)?nitrostack"
r"(?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*\S+)?"
r"(?:\s*@\s*\S+)?$",
re.IGNORECASE,
)

_SKIP_DIR_NAMES = {
"__pycache__",
".venv",
"venv",
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"node_modules",
"templates",
".egg-info",
}

_QUOTED_NITROSTACK = re.compile(
r'(["\'])nitrostack(?:\s*(?:===|==|!=|~=|>=|<=|>|<)\s*[^"\']+)?'
r'(?:\s*@\s*file:[^"\']+)?\1',
re.IGNORECASE,
)


def running_nitrostack_root() -> Path:
"""Directory that contains the `nitrostack` package used by this CLI."""
import nitrostack

package_dir = Path(nitrostack.__file__).resolve().parent
parent = package_dir.parent
pyproject = parent / "pyproject.toml"
if pyproject.is_file() and "name" in pyproject.read_text(encoding="utf-8"):
return parent
return package_dir


def _ignore(_directory: str, names: list) -> list:
skipped = []
for name in names:
if name in _SKIP_DIR_NAMES or name.endswith(".egg-info"):
skipped.append(name)
return skipped


def _distribution_version() -> str:
"""Version of the SDK this CLI is running, without a hardcoded fallback."""
import nitrostack

declared = getattr(nitrostack, "__version__", None)
if isinstance(declared, str) and declared.strip():
return declared.strip()
try:
from importlib.metadata import version as dist_version

found = dist_version("nitrostack")
except Exception:
found = ""
if found.strip():
return found.strip()
raise RuntimeError(
"Cannot determine the nitrostack package version. "
"Install the SDK so its distribution metadata is available."
)


def _write_minimal_pyproject(dest: Path) -> None:
version = _distribution_version()
dest.mkdir(parents=True, exist_ok=True)
(dest / "pyproject.toml").write_text(
"[build-system]\n"
'requires = ["setuptools>=61.0.0"]\n'
'build-backend = "setuptools.build_meta"\n\n'
"[project]\n"
'name = "nitrostack"\n'
f'version = "{version}"\n'
'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'
"]\n\n"
"[project.scripts]\n"
'nitrostack-py = "nitrostack.cli.main:main"\n\n'
"[tool.setuptools.packages.find]\n"
'include = ["nitrostack*"]\n\n'
"[tool.setuptools]\n"
"include-package-data = true\n\n"
"[tool.setuptools.package-data]\n"
'nitrostack = ["transports/assets/*"]\n',
encoding="utf-8",
)


def copy_running_nitrostack(dest: Path) -> None:
"""Copy the CLI's nitrostack package into ``dest`` (a installable tree)."""
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True, exist_ok=True)

root = running_nitrostack_root()
src_package = root / "nitrostack"
if not src_package.is_dir():
src_package = root
shutil.copytree(src_package, dest / "nitrostack", ignore=_ignore, dirs_exist_ok=True)

src_pyproject = root / "pyproject.toml"
if src_pyproject.is_file() and src_package != root:
shutil.copy2(src_pyproject, dest / "pyproject.toml")
manifest = root / "MANIFEST.in"
if manifest.is_file():
shutil.copy2(manifest, dest / "MANIFEST.in")
else:
_write_minimal_pyproject(dest)

assets = dest / "nitrostack" / "transports" / "assets" / "landing.html"
if not assets.is_file():
raise RuntimeError(
f"Vendored SDK is missing the documentation page at {assets}. "
"Update nitrostack and retry."
)


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)


def _pin_requirements(path: Path) -> None:
"""Keep the requirement name ``nitrostack`` so uv resolves ``[tool.uv.sources]``."""
if not path.is_file():
return
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
out = []
for line in lines:
stripped = line.strip()
if stripped in _PATH_PINS or _NAMED_REQUIREMENT.match(stripped):
out.append(f"{_PLAIN_REQUIREMENT}\n")
else:
out.append(line)
path.write_text("".join(out), encoding="utf-8")


def _ensure_uv_source(text: str) -> str:
if re.search(r"^\[tool\.uv\.sources\]", text, re.MULTILINE):
if re.search(r"(?m)^nitrostack\s*=", text):
return re.sub(
r"(?m)^nitrostack\s*=\s*.*$",
'nitrostack = { path = "vendor/nitrostack" }',
text,
count=1,
)
return re.sub(
r"(?m)^(\[tool\.uv\.sources\]\s*)",
r'\1nitrostack = { path = "vendor/nitrostack" }\n',
text,
count=1,
)
if not text.endswith("\n"):
text += "\n"
return text + '\n[tool.uv.sources]\nnitrostack = { path = "vendor/nitrostack" }\n'


def pin_project_to_vendor(project: Path) -> None:
pyproject = project / "pyproject.toml"
if pyproject.is_file():
text = pyproject.read_text(encoding="utf-8")
write_text_atomic(str(pyproject), _ensure_uv_source(_pin_text(text)))
_pin_requirements(project / "requirements.txt")


def ensure_vendored_nitrostack(project: str, *, quiet: bool = False) -> Optional[str]:
"""Copy this CLI's SDK into ``project/vendor/nitrostack`` and pin deps.

Returns the vendor path, or None when the directory is not a project.
"""
root = Path(os.path.abspath(project))
if not (root / "pyproject.toml").is_file() and not (root / "requirements.txt").is_file():
return None
if (root / "nitrostack" / "transports" / "assets" / "landing.html").is_file():
return None
dest = root / VENDOR_REL
copy_running_nitrostack(dest)
pin_project_to_vendor(root)
if not quiet:
print(f"Vendored NitroStack SDK at {dest}")
return str(dest)
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/app.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/context.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/di.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/errors.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/module.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/pipeline.cpython-312.pyc
Binary file not shown.
Binary file removed nitrostack/core/__pycache__/task.cpython-312.pyc
Binary file not shown.
56 changes: 56 additions & 0 deletions nitrostack/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,59 @@ def resolve_http_host() -> str:
return (os.environ.get("HOST") or "127.0.0.1").strip() or "127.0.0.1"


def _read_dotenv_value(root: str, key: str) -> Optional[str]:
path = os.path.join(root, ".env")
if not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
if name.strip() != key:
continue
return value.strip().strip('"').strip("'") or None
except OSError:
return None
return None


def _project_identity(root: Optional[str] = None) -> Dict[str, Optional[str]]:
"""Name / version / description for the `GET /` documentation page."""
cwd = root or os.getcwd()
name = os.environ.get("SERVER_NAME") or _read_dotenv_value(cwd, "SERVER_NAME")
description = os.environ.get("SERVER_DESC") or _read_dotenv_value(cwd, "SERVER_DESC")
version = None
path = os.path.join(cwd, "pyproject.toml")
if os.path.isfile(path):
try:
text = Path(path).read_text(encoding="utf-8")
except OSError:
text = ""
if not name:
match = re.search(r'(?m)^name\s*=\s*"([^"]+)"', text)
name = match.group(1) if match else None
if not description:
match = re.search(r'(?m)^description\s*=\s*"([^"]+)"', text)
description = match.group(1) if match else None
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
version = match.group(1) if match else None
if not name:
name = os.path.basename(os.path.abspath(cwd)) or None
return {"name": name, "version": version, "description": description}


def _apply_project_identity(config: "ServerConfig") -> None:
ident = _project_identity()
generic = (config.name or "").strip().lower() in {"", "app", "mcp-server"}
if generic and ident.get("name"):
config.name = ident["name"]
if not config.description and ident.get("description"):
config.description = ident["description"]


@dataclass
class ServerConfig:
name: str
Expand All @@ -149,6 +202,8 @@ class ServerConfig:
session_timeout_ms: Optional[int] = None
json_response: bool = False
extensions: Optional[Dict[str, str]] = None
# Subtitle on the `GET /` documentation page.
description: Optional[str] = None


def mcp_app(module: Type, server: ServerConfig):
Expand Down Expand Up @@ -524,6 +579,7 @@ def __init__(self, app_class: Type):
self.server_config = ServerConfig(name=app_class._mcp_module_config.name or "mcp-server")
else:
raise ValueError("Invalid application class. Must be decorated with @mcp_app or @module.")
_apply_project_identity(self.server_config)

self.mcp_server: Optional[NitroStackMcpServer] = None
era_resolution = resolve_protocol_era_resolution(
Expand Down
Binary file not shown.
1 change: 1 addition & 0 deletions nitrostack/templates/flight-booking/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
.env
!.env.example
.DS_Store
vendor/
1 change: 1 addition & 0 deletions nitrostack/templates/pizzaz/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
.env
!.env.example
.DS_Store
vendor/
1 change: 1 addition & 0 deletions nitrostack/templates/starter/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
.env
!.env.example
.DS_Store
vendor/
Binary file not shown.
Loading
Loading