diff --git a/README.md b/README.md index 6074a49..daca9bf 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. The project runs that vendored copy, whatever the version spec says. -```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())