Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ jobs:
python tests/test_production.py
python tests/test_widget_metadata.py
python tests/test_transports.py
pytest tests/test_cli.py -v
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) frame
- **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 new apps (`init`), generate boilerplates (`generate`), auto-register servers with Claude (`register`), and run hot-reload development servers (`dev`).
- **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`).

---

Expand Down Expand Up @@ -50,6 +50,73 @@ Once scaffolded, follow the next steps printed by the CLI to run your server, co

---

## 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>` | `{name}_tool.py` in the current directory |
| `generate module <name>` | `{name}_module.py` in the current directory |
| `generate guard <Name>` | `guards/<name>.py` |
| `generate pipe <Name>` | `pipes/<name>.py` |
| `generate interceptor <Name>` | `interceptors/<name>.py` |
| `generate filter <Name>` | `filters/<name>.py` |
| `generate service <Name>` | `services/<name>.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
nitrostack-py upgrade --version 0.3.2 # pin a specific version
nitrostack-py upgrade --dry-run # print the change; do not edit files

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
```

`upgrade` updates the `nitrostack` dependency spec in `pyproject.toml` in place (and `requirements.txt` when it already pins nitrostack). `validate` reports missing/conflicting dependencies, `@mcp_app` modules that fail to import, and `@module()` `imports`/`exports` that are not real classes.

---

## NitroStudio Dashboard

NitroStudio is an interactive visual developer dashboard for inspecting, graphing, and testing your MCP servers.
Expand Down Expand Up @@ -210,6 +277,7 @@ python tests/test_basic.py
python tests/test_tasks.py
python tests/test_initial_tool.py
python tests/test_transports.py
pytest tests/test_cli.py -v
```

### Testing Harness
Expand Down
1 change: 1 addition & 0 deletions nitrostack/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""NitroStack Python CLI package."""
109 changes: 109 additions & 0 deletions nitrostack/cli/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Code generation for `nitrostack-py generate`."""

from __future__ import annotations

import os
import re
import sys
from typing import Dict, Optional

TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")

COMPONENT_KINDS = ("guard", "pipe", "interceptor", "filter", "service")

_KIND_DIR: Dict[str, str] = {
"guard": "guards",
"pipe": "pipes",
"interceptor": "interceptors",
"filter": "filters",
"service": "services",
}

_KIND_SUFFIX: Dict[str, str] = {
"guard": "Guard",
"pipe": "Pipe",
"interceptor": "Interceptor",
"filter": "Filter",
"service": "Service",
}


def to_pascal_case(name: str) -> str:
cleaned = name.replace("-", "_")
if "_" in cleaned:
return "".join(part.capitalize() for part in cleaned.split("_") if part)
if cleaned and cleaned[0].isupper():
return cleaned
return cleaned[:1].upper() + cleaned[1:] if cleaned else cleaned


def to_snake_case(name: str) -> str:
cleaned = name.replace("-", "_")
if "_" in cleaned:
return re.sub(r"_+", "_", cleaned).strip("_").lower()
stepped = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", cleaned)
stepped = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stepped)
return stepped.lower()


def class_name_for(kind: str, name: str) -> str:
pascal = to_pascal_case(name)
suffix = _KIND_SUFFIX[kind]
if pascal.endswith(suffix):
return pascal
return f"{pascal}{suffix}"


def _load_template(filename: str) -> str:
path = os.path.join(TEMPLATES_DIR, filename)
if not os.path.exists(path):
print(f"Error: template '{filename}' not found at '{path}'.")
sys.exit(1)
with open(path, "r", encoding="utf-8") as handle:
return handle.read()


def _write_file(path: str, content: str) -> None:
if os.path.exists(path):
print(f"Error: File '{path}' already exists.")
sys.exit(1)
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
handle.write(content)
if not content.endswith("\n"):
handle.write("\n")


def generate_component(kind: str, name: str, cwd: Optional[str] = None) -> str:
"""Render a component template and write it under the project's expected directory."""
if kind not in COMPONENT_KINDS:
print(f"Error: unknown generate target '{kind}'.")
sys.exit(1)
if not name or not re.match(r"^[A-Za-z_][A-Za-z0-9_-]*$", name):
print("Error: name must be a valid identifier (letters, numbers, '_' or '-').")
sys.exit(1)

root = cwd or os.getcwd()
class_name = class_name_for(kind, name)
snake = to_snake_case(name)
rel_path = os.path.join(_KIND_DIR[kind], f"{snake}.py")
dest = os.path.join(root, rel_path)

content = _load_template(f"{kind}.py").replace("CLASS_NAME", class_name)
_write_file(dest, content)
print(f"Generated {kind} boilerplate in '{rel_path}'")
return dest


def generate_module(name: str, cwd: Optional[str] = None) -> str:
"""Preserve existing `generate module` behavior: `{name}_module.py` in CWD."""
root = cwd or os.getcwd()
filename = f"{name}_module.py"
dest = os.path.join(root, filename)
camel_name = "".join(part.capitalize() for part in name.split("_"))
content = _load_template("module.py").format(name=name, camel_name=camel_name)
_write_file(dest, content)
print(f"Generated module boilerplate in '{filename}'")
return dest
85 changes: 85 additions & 0 deletions nitrostack/cli/install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Dependency installation wrapper for `nitrostack-py install`."""

from __future__ import annotations

import os
import re
import subprocess
import sys
from typing import List, Optional


def _read(path: str) -> str:
with open(path, "r", encoding="utf-8") as handle:
return handle.read()


def _optional_extra_names(pyproject_text: str) -> List[str]:
"""Return optional-dependency extra names (e.g. dev, test)."""
match = re.search(
r"^\[project\.optional-dependencies\](.*?)(?=^\[|\Z)",
pyproject_text,
re.MULTILINE | re.DOTALL,
)
if not match:
return []
names = re.findall(r"^([A-Za-z0-9._-]+)\s*=", match.group(1), re.MULTILINE)
return names


def _run_pip(args: List[str], cwd: str) -> None:
cmd = [sys.executable, "-m", "pip", "install", *args]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd)
if result.returncode != 0:
raise RuntimeError(
f"`pip install` failed with exit code {result.returncode}.\n"
"Fix the reported dependency error and retry `nitrostack-py install`."
)


def install_dependencies(
*,
production: bool = False,
cwd: Optional[str] = None,
) -> None:
root = os.path.abspath(cwd or os.getcwd())
pyproject = os.path.join(root, "pyproject.toml")
requirements = os.path.join(root, "requirements.txt")
dev_requirement_files = [
os.path.join(root, "requirements-dev.txt"),
os.path.join(root, "requirements.dev.txt"),
os.path.join(root, "dev-requirements.txt"),
]

if not os.path.isfile(pyproject) and not os.path.isfile(requirements):
raise RuntimeError(
"No pyproject.toml or requirements.txt found in the current directory.\n"
"Run this command from a NitroStack project."
)

print("NITROSTACK — Install" + (" (production)" if production else ""))

if os.path.isfile(pyproject):
extras: List[str] = []
if not production:
extras = _optional_extra_names(_read(pyproject))
if extras:
extra_spec = ",".join(extras)
_run_pip(["-e", f".[{extra_spec}]"], cwd=root)
else:
_run_pip(["-e", "."], cwd=root)
print("Installed pyproject.toml dependencies"
+ (" (skipped optional/dev extras)" if production else ""))
elif os.path.isfile(requirements):
_run_pip(["-r", requirements], cwd=root)
print("Installed requirements.txt")

if production:
print("Skipping development dependency files (--production).")
return

for path in dev_requirement_files:
if os.path.isfile(path):
_run_pip(["-r", path], cwd=root)
print(f"Installed {os.path.basename(path)}")
Loading