From aa3d929697599e017f006b5e13810b1851385ba0 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 06:19:16 +0530 Subject: [PATCH 1/3] Update skills for nitrostack 0.4.x; add four skills and a CI check Existing skills: fix snippets that didn't compile, add the current tool options (annotations, examples, no-argument inputs), the pipeline order, API key and JWT auth, require_scopes vs the template scope guard, and the service-layer split. ui-widgets: the bundled templates ship plain HTML; src/widgets is optional. New skills: config-environment (.env precedence, ConfigService), runtime-features (cache, rate limits, tasks, events, health), transports-cli, testing. scripts/check_skills.py checks frontmatter, compiles every python block, and checks every nitrostack import against the SDK. CI runs it on PRs, on main, and weekly against the latest PyPI release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 21 +++++ README.md | 24 ++++++ package.json | 2 +- scripts/check_skills.py | 72 +++++++++++++++++ skills/auth-security/SKILL.md | 60 +++++++++++--- skills/config-environment/SKILL.md | 62 ++++++++++++++ skills/mcp-app-architecture/SKILL.md | 31 +++++-- skills/middleware-pipeline/SKILL.md | 77 +++++++++++------- skills/runtime-features/SKILL.md | 103 ++++++++++++++++++++++++ skills/testing/SKILL.md | 50 ++++++++++++ skills/tools-resources-prompts/SKILL.md | 67 +++++++++------ skills/transports-cli/SKILL.md | 54 +++++++++++++ skills/ui-widgets/SKILL.md | 57 ++++++++----- 13 files changed, 587 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 scripts/check_skills.py create mode 100644 skills/config-environment/SKILL.md create mode 100644 skills/runtime-features/SKILL.md create mode 100644 skills/testing/SKILL.md create mode 100644 skills/transports-cli/SKILL.md diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..3856cf7 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,21 @@ +name: Check skills + +on: + pull_request: + push: + branches: [main] + # The SDK can rename or drop an export without a change here. + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install nitrostack + - run: python scripts/check_skills.py diff --git a/README.md b/README.md index ade4918..a1fcded 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,28 @@ Skill files copied into generated projects by `nitrostack-py init` (and refreshe Layout: each subdirectory of `skills/` is one skill (typically a `SKILL.md`). The CLI clones this repository and copies those directories into `.cursor/skills`, `.claude/skills`, `.copilot/skills`, and the other supported agent folders. +| Skill | Covers | +|---|---| +| `mcp-app-architecture` | `main.py`, `AppModule`, feature modules, DI, services | +| `tools-resources-prompts` | `@tool`, `@resource`, `@prompt`, Pydantic schemas, examples | +| `middleware-pipeline` | Guards, pipes, middleware, interceptors, exception filters | +| `auth-security` | API keys, JWT, OAuth 2.1, scopes | +| `config-environment` | `ConfigService`, `.env` precedence, SDK variables | +| `runtime-features` | Caching, rate limits, MCP tasks, events, health checks | +| `transports-cli` | stdio/http/dual, protocol era, `nitrostack-py` commands | +| `testing` | `NitroTestingModule` and pytest | +| `ui-widgets` | `@widget`, `widgets/out`, app modes, Inspector | + +Every project created by `init` clones `main`, so a change lands in new projects as soon as it merges. + +## Checks + +`scripts/check_skills.py` runs in CI against the latest `nitrostack` on PyPI. It checks that every skill has a unique `name` and a `description`, every `python` block compiles, and every `from nitrostack... import` names something the SDK has. Run it locally with: + +```bash +uv run --no-project --with nitrostack python scripts/check_skills.py +# or against a local SDK checkout: +uv run --no-project --with-editable ../nitrostack-python-sdk python scripts/check_skills.py +``` + Bump `package.json` `version` when skill content changes so `upgrade` can detect a newer release. diff --git a/package.json b/package.json index cf22e8a..4c9a143 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nitrostack/skills-python-sdk", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Agent skills for NitroStack Python SDK projects generated by nitrostack-py init" } diff --git a/scripts/check_skills.py b/scripts/check_skills.py new file mode 100644 index 0000000..c77a29c --- /dev/null +++ b/scripts/check_skills.py @@ -0,0 +1,72 @@ +"""Check every skill against the installed nitrostack SDK. + +Every project created by `nitrostack-py init` copies these skills, so a wrong +import here becomes a wrong import in generated code. For each SKILL.md: + +- the frontmatter has a unique `name` and a `description`; +- every ```python block compiles; +- every `from nitrostack... import X` names something the SDK has. + +Run: python scripts/check_skills.py +""" +from __future__ import annotations + +import importlib +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +FENCE = re.compile(r"^```python\n(.*?)^```", re.S | re.M) +IMPORT = re.compile(r"^\s*from (nitrostack[\w.]*) import (\([^)]*\)|[^\n]*)", re.M) +FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S) + + +def check(path: pathlib.Path, seen_names: dict) -> list: + errors = [] + text = path.read_text(encoding="utf-8") + rel = path.relative_to(ROOT) + + match = FRONTMATTER.match(text) + fields = dict(re.findall(r"^(\w+):\s*(.+)$", match.group(1), re.M)) if match else {} + if not fields.get("name") or not fields.get("description"): + errors.append(f"{rel}: frontmatter needs name and description") + elif fields["name"] in seen_names: + errors.append(f"{rel}: name {fields['name']!r} also used by {seen_names[fields['name']]}") + else: + seen_names[fields["name"]] = rel + + for i, code in enumerate(FENCE.findall(text), 1): + try: + compile(code, f"{rel} python block {i}", "exec") + except SyntaxError as exc: + errors.append(f"{rel}: python block {i} doesn't compile: {exc}") + for mod_name, names in IMPORT.findall(code): + try: + mod = importlib.import_module(mod_name) + except ImportError as exc: + errors.append(f"{rel}: python block {i}: {exc}") + continue + for name in re.split(r"[,\s()]+", names): + if name and not name.startswith("#") and not hasattr(mod, name): + errors.append(f"{rel}: python block {i} imports {name!r} from {mod_name}, which doesn't have it") + return errors + + +def main() -> int: + from importlib.metadata import version + + skills = sorted((ROOT / "skills").glob("*/SKILL.md")) + if not skills: + print("No skills found") + return 1 + seen: dict = {} + errors = [e for path in skills for e in check(path, seen)] + for error in errors: + print(error) + print(f"{len(skills)} skills checked against nitrostack {version('nitrostack')}: {len(errors)} problem(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/auth-security/SKILL.md b/skills/auth-security/SKILL.md index 2a0ea9a..02581f8 100644 --- a/skills/auth-security/SKILL.md +++ b/skills/auth-security/SKILL.md @@ -1,19 +1,31 @@ --- name: nitrostack-python-auth-security -description: OAuth 2.1, OAuthModule, OAuthGuard, and scope guards in NitroStack Python MCP servers. +description: API keys, JWT, OAuth 2.1 (OAuthModule, OAuthGuard), and scope checks in NitroStack Python MCP servers. --- ## When to Use -Protecting Python NitroStack tools (oauth template, Auth0/introspection). Not the TypeScript `OAuthModule` Nest package. +Protecting Python NitroStack tools: API keys, JWTs, or OAuth 2.1 (oauth template, Auth0/introspection). Not the TypeScript `OAuthModule` Nest package. + +## Pick a module and guard + +Import the module in the root `AppModule`, then guard tools. Guards fill `context.auth` (`subject`, `scopes`, `client_id`, `aud`, `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=[...], ...)` | `OAuthGuard` | OAuth 2.1 access tokens, audience-bound | ## Wire OAuth on the root module -The `python-oauth` template imports `OAuthModule.for_root(...)` next to `ConfigModule` and the feature module. Values come from `.env` (`RESOURCE_URI`, `AUTH_SERVER_URL`, introspection client, audience, issuer). Follow `OAUTH_SETUP.md` in the generated project. +The `python-oauth` template imports `OAuthModule.for_root(...)` next to `ConfigModule` and the feature module. Values come from `.env` (`RESOURCE_URI`, `AUTH_SERVER_URL`, introspection client, audience, issuer). Follow `OAUTH_SETUP.md` in the generated project. Use `jwks_uri=...` instead of the introspection settings to verify JWT access tokens locally. ```python -from nitrostack import module, ConfigModule, OAuthModule import os +from nitrostack import module, ConfigModule, OAuthModule +from modules.flights.flights_module import FlightsModule +from health.system_health import SystemHealthCheck @module( name="app", @@ -40,23 +52,45 @@ class AppModule: ## Protect tools ```python -from nitrostack import use_guards, OAuthGuard -from guards.oauth_guard import create_scope_guard +from pydantic import BaseModel +from nitrostack import injectable, tool, use_guards, require_scopes, OAuthGuard, ExecutionContext +from guards.oauth_guard import create_scope_guard # in the oauth template -@use_guards(OAuthGuard, create_scope_guard(["read"])) +class SearchInput(BaseModel): + origin: str + destination: str + +@injectable() +class FlightsTools: + @tool(name="search_flights", description="Search flights", input_schema=SearchInput) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def search_flights(self, input: SearchInput, context: ExecutionContext) -> dict: + return {"offers": []} + + @tool(name="cancel_order", description="Cancel an order", input_schema=SearchInput) + @use_guards(OAuthGuard) + @require_scopes("admin") + async def cancel_order(self, input: SearchInput, context: ExecutionContext) -> dict: + return {"cancelled": True} ``` `OAuthGuard`: -- Reads Bearer token from metadata/headers (HTTP `Authorization` is forwarded into execution metadata). -- If **no token** and `OAUTH_REQUIRED` is not true → allow (local Studio/Inspector). -- If **no token** and `OAUTH_REQUIRED=true` → `PermissionError`. -- If token present → `OAuthService.introspect_token`; populate `context.auth` (`subject`, `scopes`, `aud` as a list, claims). +- Reads the Bearer token from metadata/headers (HTTP `Authorization` is forwarded into execution metadata). +- If **no token** and `OAUTH_REQUIRED` is not true: allow (local Studio/Inspector). +- If **no token** and `OAUTH_REQUIRED=true`: `PermissionError`. +- If a token is present: validate it with `OAuthService`, then populate `context.auth` (`aud` is a list). + +Two scope checks behave differently without a token: + +- The template's `create_scope_guard(["read"])` no-ops when OAuth is not required, so local calls still work. +- `require_scopes(...)` always needs `context.auth`: with no token it fails with "Not authenticated", even when `OAUTH_REQUIRED` is unset. -`create_scope_guard(["read"])` no-ops when oauth is not required; otherwise requires those scopes on `context.auth.scopes`. +Inside a tool, `has_scope`, `has_any_scope` and `has_all_scopes` check `context.auth`. PKCE (`generate_pkce_params`), client ID metadata documents and `generate_www_authenticate_header` are exported for custom flows. ## Do not - Copy TypeScript `OAuthGuard` / Passport strategies. -- Check `"aud" in context.auth.aud` assuming `aud` is a string — it is normalized to a list. +- Check `"aud" in context.auth.aud` assuming `aud` is a string. It is normalized to a list. +- Use `ApiKeyGuard` without `ApiKeyModule.for_root()` or an `API_KEY` variable: it then allows every call. - Put secrets in skill files or commit `.env`. diff --git a/skills/config-environment/SKILL.md b/skills/config-environment/SKILL.md new file mode 100644 index 0000000..cff8f2f --- /dev/null +++ b/skills/config-environment/SKILL.md @@ -0,0 +1,62 @@ +--- +name: nitrostack-python-config-environment +description: Settings in NitroStack Python — ConfigModule, ConfigService, .env precedence, and the environment variables the SDK reads. +--- + +## When to Use + +Reading settings or secrets, editing `.env`, or debugging a server that picks up the wrong port, name or transport. + +## Precedence + +Highest first: + +1. The process environment (shell, NitroStudio, Docker, CI). +2. The project's `.env`. +3. `ConfigModule.for_root(defaults={...})`. + +`.env` never overwrites a variable that is already set (nitrostack 0.4.1 and later). NitroStudio starts each project with its own `PORT`, so several servers can run side by side even though `.env` says `PORT=3000`. On older SDKs `.env` won. Projects run the SDK copy in `vendor/nitrostack`, so check its version (`vendor/nitrostack/pyproject.toml`) when `.env` seems to override the environment. + +## Read settings with 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 +``` + +Inject `ConfigService` rather than reading `os.environ` in services: it applies the precedence above and the module defaults. + +## Variables the SDK reads + +| Variable | Meaning | +|---|---| +| `PORT` / `MCP_SERVER_PORT` | HTTP port (default 3000). Studio sets it; don't hard-code a port in code. | +| `HOST` | Bind address (default `127.0.0.1`; `0.0.0.0` in containers). | +| `SERVER_NAME` / `SERVER_DESC` | Name and description clients show. `init` sets `SERVER_NAME` to the folder name. | +| `MCP_TRANSPORT_TYPE` | `stdio`, `http` or `dual` (transports-cli skill). | +| `MCP_STATELESS` | `true` for stateless HTTP (MCP Inspector). | +| `NITROSTACK_APP_MODE` | Widget metadata: `universal` (default), `openai`, `mcp-app`. | +| `WIDGETS_DEV_PORT` | Port of the optional `src/widgets` dev server (default 3001). | +| `OAUTH_REQUIRED` | `true` makes `OAuthGuard` reject calls without a token. | +| `API_KEY`, `API_KEY_1`, ... | Keys for `ApiKeyModule` / `ApiKeyGuard`. | +| `NITROSTACK_LOG_LEVEL` / `NITROSTACK_LOG_FILE` | Logging (default file `nitrostack.log`). | +| `NITROSTACK_HTTP_DEBUG` | `1` logs every HTTP request to stderr. | + +## Do not + +- Commit `.env` or put secrets in code. Add new keys to `.env.example` with placeholder values. +- Remove `PORT` handling or bind a fixed port in `main.py`. +- Print `ConfigService` values that hold secrets to the log. diff --git a/skills/mcp-app-architecture/SKILL.md b/skills/mcp-app-architecture/SKILL.md index 67fcc2f..179b0b4 100644 --- a/skills/mcp-app-architecture/SKILL.md +++ b/skills/mcp-app-architecture/SKILL.md @@ -24,7 +24,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -Optional: `@mcp_app(module=AppModule, server=ServerConfig(name="...", version="1.0.0"))` exists on a class, but factory-from-module is the template path. +Optional: `@mcp_app(module=AppModule, server=ServerConfig(name="...", version="1.0.0"))` on a class sets the server name, transport and session limits in code (transports-cli skill). Factory-from-module is the template path. + +The server name clients show comes from `SERVER_NAME` (set to the project folder by `init`) whenever the configured name is generic (`app`, `mcp-server`). Rename the project by changing `SERVER_NAME` in `.env`, not the root module's `name`. ## Root AppModule @@ -45,11 +47,18 @@ class AppModule: pass ``` -`@module(...)` takes `name`, `controllers`, `providers`, `imports`, `exports`. Feature tools go on **controllers**. Injectable services go on **providers**. `imports` pull in other modules (and `ConfigModule` / `OAuthModule`). +`@module(...)` takes `name`, `controllers`, `providers`, `imports`, `exports`. Feature tools go on **controllers**. Injectable services go on **providers**. `imports` pull in other modules (and `ConfigModule`, `ApiKeyModule`, `JWTModule`, `OAuthModule`). ## Feature modules -Put `@tool` / `@resource` / `@prompt` classes in `controllers`. Put services the controllers construct in `providers` and `exports` if other modules need them. +One directory per feature under `modules/`. Put `@tool` / `@resource` / `@prompt` classes in `controllers`. Put services in `providers`, and in `exports` if other modules need them. + +``` +modules/fixtures/ + fixtures_module.py # @module(...) + fixtures_service.py # DB queries, business logic + fixtures_tools.py # @tool methods that call the service +``` ```python from nitrostack import module @@ -65,25 +74,35 @@ class CalculatorModule: pass ``` +Scaffold with `nitrostack-py generate module ` and `nitrostack-py generate service `. + ## Dependency injection -Mark classes `@injectable()` or `@injectable(deps=[SomeService])`. Constructor args must match `deps`. The container instantiates controllers and providers; do not `SomeService()` by hand inside a tool class. +Mark classes `@injectable()` or `@injectable(deps=[SomeService])`. Constructor args must match `deps`. The container instantiates controllers and providers as singletons; do not call `SomeService()` by hand inside a tool class. ```python from nitrostack import injectable +@injectable() +class PizzazService: + def list_shops(self) -> list: + return [] + @injectable(deps=[PizzazService]) class PizzazTools: def __init__(self, service: PizzazService): self.service = service ``` -## Health checks +Settings come from `ConfigService` (config-environment skill). + +## Health checks and events -Register a provider with `@health_check("system")` on a method that returns a bool (see `health/system_health.py`). +Register a provider with `@health_check("system")` on a method that returns a bool (see `health/system_health.py`). Events, caching, rate limits and long-running tasks are in the runtime-features skill. ## Do not - Import `@nitrostack/core` or write TypeScript decorators. - Put `@tool` methods on a module class that is only a `@module` config holder. - Skip `ConfigModule.for_root` if the app reads `.env`. +- Write database queries inside tool methods. Put them in a service. diff --git a/skills/middleware-pipeline/SKILL.md b/skills/middleware-pipeline/SKILL.md index 65606ad..f0b9ba1 100644 --- a/skills/middleware-pipeline/SKILL.md +++ b/skills/middleware-pipeline/SKILL.md @@ -7,43 +7,66 @@ description: Guards, middleware, interceptors, pipes, and exception filters on N Authorizing or transforming a tool/resource/prompt call in the **Python** SDK (`nitrostack.core.pipeline`). -## Stacking on a handler +## Stages -Decorators attach lists on the function: `_mcp_guards`, `_mcp_middleware`, `_mcp_interceptors`, `_mcp_pipes`, `_mcp_filters`. +Each stage is a small class with one async method. Attach it with a decorator below `@tool`. -```python -from nitrostack import tool, use_guards, OAuthGuard, ExecutionContext -from guards.oauth_guard import create_scope_guard - -@tool(name="search_flights", description="...", input_schema=SearchFlightsInput) -@use_guards(OAuthGuard, create_scope_guard(["read"])) -async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict: - ... -``` +| 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: PipeMetadata)` | `@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 | -Same pattern: `use_middleware`, `use_interceptors`, `use_pipes`, `use_filters`. +`PipeMetadata` (`param_name`, `param_type`) is imported from `nitrostack.core.pipeline`. -## Protocols - -Implement these as classes (often returned from a factory): +```python +from pydantic import BaseModel +from nitrostack import injectable, tool, use_guards, use_interceptors, use_filters, ExecutionContext + +class LookupInput(BaseModel): + city: str + +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)} + +@injectable() +class WeatherTools: + @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"} +``` -- **Guard** — `async def can_activate(self, context: ExecutionContext) -> bool` -- **Middleware** — `async def use(self, context, next_fn)` -- **Interceptor** — `async def intercept(self, context, next_fn)` -- **Pipe** — `async def transform(self, value, metadata: PipeMetadata)` -- **ExceptionFilter** — `async def catch(self, error, context)` +A guard factory (a function returning a guard class) is fine when the guard needs parameters; see `create_scope_guard` in the oauth template. -Scaffold with `nitrostack-py generate guard|pipe|interceptor|filter `. +Scaffold with `nitrostack-py generate guard|pipe|interceptor|filter ` (writes `guards/`, `pipes/`, `interceptors/`, `filters/`). -## Built-ins +## Built-in guards -- `ApiKeyGuard` — `x-api-key` in metadata/headers; `ApiKeyService` or `API_KEY` env. -- `JwtGuard` — `Authorization: Bearer`; fills `context.auth`. -- `OAuthGuard` — introspects the access token; see the auth-security skill. +- `ApiKeyGuard`: checks the `x-api-key` header against `ApiKeyModule` keys. Register `ApiKeyModule.for_root()` when you use it: without the module the guard falls back to the `API_KEY` variable, and with neither set it **lets every call through**. +- `JwtGuard`: `Authorization: Bearer `, configured by `JWTModule`; fills `context.auth`. +- `OAuthGuard`: introspects or verifies the OAuth access token; see the auth-security skill. +- `require_scopes("admin")`: stack under an auth guard; rejects unless `context.auth` has every listed scope. -Read tokens from `context.metadata` (`authorization`, `headers`, `_oauth`). Do not assume Express/Nest request objects. +Read tokens and request data from `context.metadata` (`authorization`, `headers`, `_oauth`). There are no Express/Nest request objects. ## Do not - Use TypeScript `UseGuards()` / Nest middleware. -- Fail closed on missing OAuth when `OAUTH_REQUIRED` is unset — `OAuthGuard` allows no-token when oauth is not required (Studio mock flights). +- Fail closed on missing OAuth when `OAUTH_REQUIRED` is unset. `OAuthGuard` allows no-token calls when OAuth is not required (local Studio/Inspector). +- Use `ApiKeyGuard` without `ApiKeyModule` or `API_KEY`: it then protects nothing. diff --git a/skills/runtime-features/SKILL.md b/skills/runtime-features/SKILL.md new file mode 100644 index 0000000..8093901 --- /dev/null +++ b/skills/runtime-features/SKILL.md @@ -0,0 +1,103 @@ +--- +name: nitrostack-python-runtime-features +description: Caching, rate limits, long-running MCP tasks with progress, events, and health checks in NitroStack Python. +--- + +## When to Use + +A tool is slow or expensive (cache it, rate-limit it, or run it as a task), or one part of the app must react to another (events), or the server needs a health signal. + +## Caching and rate limits + +```python +from pydantic import BaseModel +from nitrostack import cache, injectable, rate_limit, tool, ExecutionContext + +class NoInput(BaseModel): + pass + +@injectable() +class WeatherService: + @cache(ttl=60) # seconds; keyed on the arguments + async def lookup(self, city: str) -> dict: + return {"city": city, "temp": 21} + +@injectable(deps=[WeatherService]) +class WeatherTools: + def __init__(self, service: WeatherService): + self.service = service + + @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). On a tool method it fails with `unhashable type`, because the tool's input is a Pydantic object. Over the limit, `@rate_limit` returns "Rate limit exceeded". + +## 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, and gets live `notifications/progress` when it sends a `progressToken`. `context.task` is `None` on a normal call, so guard every use. + +```python +import asyncio +from pydantic import BaseModel +from nitrostack import injectable, tool, ExecutionContext + +class ReportInput(BaseModel): + steps: int = 5 + +@injectable() +class ReportTools: + @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 + +```python +from typing import List +from nitrostack import EventEmitter, 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"]) +``` + +`@on_event` methods are bound on any provider or controller of a module at startup. The emitter is in-process; it is not a message queue. + +## Health checks + +```python +from nitrostack import health_check, injectable + +@injectable() +class DatabaseHealth: + @health_check("database") + def check(self) -> bool: + return True +``` + +When any health check exists, the server exposes the `health://status` resource (`{"database": "healthy"}`). Keep health checks synchronous and fast. Over HTTP, `GET /mcp/health` reports the transport (sessions, uptime). + +## Do not + +- Cache tool methods, or results that depend on the caller's identity. +- Call `context.task.update_progress` without checking `context.task`. +- Use `async def` health checks. diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 0000000..7fc493d --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,50 @@ +--- +name: nitrostack-python-testing +description: Test NitroStack Python tools, resources, and prompts in-process with NitroTestingModule and pytest. +--- + +## When to Use + +Writing or running tests for a Python NitroStack server, or checking a tool change before connecting a client. + +## In-process harness + +`NitroTestingModule.create(AppModule)` boots the app's DI container and registries without a transport or subprocess. + +```python +import asyncio +from nitrostack import DIContainer, NitroTestingModule +from app_module import AppModule + +def test_calculate(): + async def run(): + harness = await NitroTestingModule.create(AppModule) + result = await harness.call_tool("calculate", {"operation": "add", "a": 5, "b": 3}) + assert result["result"] == 8 + assert "routes" in await harness.read_resource("flight://popular-routes") + messages = await harness.get_prompt("flight_search_assistant", {"destination": "Oslo"}) + assert "Oslo" in str(messages) + + DIContainer.reset() + asyncio.run(run()) +``` + +- `call_tool(name, args)` takes the arguments as an MCP client sends them (a flat dict, not the Pydantic model) and returns the decoded result: JSON is parsed. +- Errors come back as their message string, not as exceptions. Assert on the text: `assert "Access denied" in str(result)`. +- A tool with no arguments is called with `{}`. +- Singletons live for the process. Call `DIContainer.reset()` between tests (an autouse pytest fixture works well) so state from one test doesn't leak into the next. +- Set environment variables with pytest's `monkeypatch.setenv` before `create`; the process environment wins over `.env`. + +## Running + +```bash +pip install pytest # or: uv add --dev pytest +pytest +``` + +Keep tests in `tests/`, and make test inputs match what the tools expect: real IDs, exact ISO dates, names the service can resolve. + +## Do not + +- Start the HTTP server or a subprocess to test tool logic. +- Call controller methods directly; that skips guards, pipes and validation. diff --git a/skills/tools-resources-prompts/SKILL.md b/skills/tools-resources-prompts/SKILL.md index 08d4826..e072121 100644 --- a/skills/tools-resources-prompts/SKILL.md +++ b/skills/tools-resources-prompts/SKILL.md @@ -9,48 +9,60 @@ Defining, editing, or validating tools, resources, or prompts on a **Python** Ni ## Tools -Decorate **async methods on an `@injectable` class** (listed in the module’s `controllers`). Signature is `(self, input: Model, context: ExecutionContext)`. +Decorate **async methods on an `@injectable` class** listed in the module's `controllers`. Signature is `(self, input: Model, context: ExecutionContext)`; `input` is an instance of `input_schema`. ```python -from nitrostack import injectable, tool, widget, ExecutionContext -from pydantic import BaseModel, Field from typing import Literal +from pydantic import BaseModel, Field +from nitrostack import injectable, tool, widget, ToolAnnotations, ToolExamples, ExecutionContext class CalculateInput(BaseModel): operation: Literal["add", "subtract", "multiply", "divide"] = Field(description="The operation to perform") a: float = Field(description="First number") b: float = Field(description="Second number") +class CalculateOutput(BaseModel): + result: float + expression: str + @injectable() class CalculatorTools: @tool( name="calculate", + title="Calculate", description="Perform basic arithmetic calculations", input_schema=CalculateInput, - output_schema=CalculateOutput, # optional Pydantic model + output_schema=CalculateOutput, # optional; adds structuredContent + annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), + examples=ToolExamples( + input={"operation": "add", "a": 5, "b": 3}, + output={"result": 8, "expression": "5 + 3 = 8"}, + ), ) @widget("calculator-result") async def calculate(self, input: CalculateInput, context: ExecutionContext) -> dict: context.logger.info(f"{input.operation} {input.a} {input.b}") - return {"result": input.a + input.b, ...} + result = input.a + input.b + return {"result": result, "expression": f"{input.a:g} + {input.b:g} = {result:g}"} ``` -`@tool` keyword args: `name`, `description`, `input_schema` (required), plus optional `title`, `output_schema`, `annotations`, `task_support`, `visibility`, `examples` (`ToolExamples`), `invocation`, `metadata`. +`@tool` keyword args: `name`, `description`, `input_schema` (required), plus optional `title`, `output_schema`, `annotations` (`ToolAnnotations`), `task_support` (see the runtime-features skill), `visibility`, `examples` (`ToolExamples`), `invocation`, `metadata`. -Stack `@initial_tool` (before or after `@tool`) to auto-invoke on client connect. - -Inspector sends empty strings for unused optionals; use Pydantic `field_validator(..., mode="before")` to coerce `""` to defaults when needed. +- Always pass `examples`: client LLMs use them to get argument shapes right. +- A tool with no arguments still needs an input model. Use an empty subclass (`class NoInput(BaseModel): pass`), never `BaseModel` itself, which fails at startup. +- `visibility="hidden"` keeps a tool callable but out of `tools/list`. +- Raise an exception to return an MCP error to the client. +- Stack `@initial_tool` (before or after `@tool`) to auto-invoke on client connect. +- Inspector sends empty strings for unused optionals; use Pydantic `field_validator(..., mode="before")` to coerce `""` to defaults when needed. +- Keep database queries and business logic in a service (`providers`), injected into the controller. The controller only maps input to service calls. ## Resources ```python from nitrostack import injectable, resource, ExecutionContext -@injectable(deps=[DuffelService]) +@injectable() class FlightResources: - def __init__(self, service: DuffelService): - self.service = service - @resource( uri="flight://popular-routes", name="Popular Flight Routes", @@ -58,26 +70,33 @@ class FlightResources: mime_type="application/json", ) async def popular_routes(self, context: ExecutionContext) -> dict: - return {"routes": [...]} + return {"routes": [{"from": "JFK", "to": "LHR"}]} ``` -List the class on the feature module `controllers`. +Resource handlers take `(self, context)`. List the class on the feature module `controllers`. ## Prompts +Prompt handlers take `(self, args: dict, context)` and return a string or a list of `PromptMessage`. Declare arguments with `PromptArgument` so clients can ask for them. + ```python -from nitrostack import injectable, prompt, ExecutionContext - -@prompt( - name="flight_search_assistant", - description="Help users search for flights and book holds.", -) -async def flight_search_assistant(self, args: dict, context: ExecutionContext) -> str: - return "Use search_flights and search_airports, then create an order hold." +from typing import List +from nitrostack import injectable, prompt, PromptArgument, PromptMessage, ExecutionContext + +@injectable() +class FlightPrompts: + @prompt( + name="flight_search_assistant", + description="Help users search for flights and book holds.", + arguments=[PromptArgument(name="destination", description="Where to fly", required=True)], + ) + async def flight_search_assistant(self, args: dict, context: ExecutionContext) -> List[PromptMessage]: + return [PromptMessage(role="user", content=f"Find flights to {args['destination']} with search_flights.")] ``` ## Do not - Use `z.object` / Zod `inputSchema`. - Register a bare function as a tool without a controller class unless you are matching an existing example in this repo. -- Invent Nest-style `@Controller('prefix')` — Python tools use the `name=` string as the MCP tool name. +- Invent Nest-style `@Controller('prefix')`. Python tools use the `name=` string as the MCP tool name. +- Put `@cache` on a tool method: it can't key on the Pydantic input. Cache a service method instead (runtime-features skill). diff --git a/skills/transports-cli/SKILL.md b/skills/transports-cli/SKILL.md new file mode 100644 index 0000000..2a8c4b9 --- /dev/null +++ b/skills/transports-cli/SKILL.md @@ -0,0 +1,54 @@ +--- +name: nitrostack-python-transports-cli +description: Running a NitroStack Python server — stdio/http/dual transports, protocol era, and the nitrostack-py CLI (dev, generate, upgrade, pack, register). +--- + +## When to Use + +Starting, deploying or connecting a Python NitroStack server, choosing a transport, or running `nitrostack-py` commands. + +## Transports + +Chosen by `MCP_TRANSPORT_TYPE` (or `ServerConfig(transport_type=...)`): + +- `stdio` (default outside production): desktop clients such as Claude Desktop and Cursor launch the process. +- `http`: Streamable HTTP on `PORT`. Endpoints: `POST/GET/DELETE /mcp` (no trailing slash needed), legacy `GET /sse` + `POST /mcp/messages/`, `GET /mcp/health`, and a docs page at `GET /`. +- `dual` (default when `NODE_ENV=production`): stdio and HTTP in one process, sharing the same singletons. + +Under stdio, stdout carries the protocol. Log through `context.logger`, never `print()`. + +```python +from nitrostack import mcp_app, ServerConfig +from app_module import AppModule + +@mcp_app( + module=AppModule, + server=ServerConfig(name="my-server", version="1.0.0", transport_type="http", max_sessions=100), +) +class App: + pass +``` + +Protocol era: `MCP_STATELESS=true` forces stateless HTTP (MCP Inspector), `false` forces sessions. Otherwise `NITRO_MCP_PROTOCOL_VERSION` (`auto` default, `modern`, `legacy`), then `ServerConfig.protocol_era`. + +## CLI + +| Command | Does | +|---|---| +| `nitrostack-py init --template python-starter\|python-pizzaz\|python-oauth` | New project, dependencies, widgets, agent skills | +| `nitrostack-py dev` | Hot-reload server (plus `src/widgets` dev server when present) | +| `nitrostack-py start` | Production server | +| `nitrostack-py generate tool\|module\|guard\|pipe\|interceptor\|filter\|service ` | Boilerplate | +| `nitrostack-py install [--production]` | Install dependencies (`uv sync` when uv is on PATH) | +| `nitrostack-py upgrade [--version X.Y.Z] [--dry-run]` | Update the `nitrostack` dependency spec and refresh agent skills | +| `nitrostack-py validate` | Check dependencies, `@mcp_app` imports and `@module()` references | +| `nitrostack-py pack [--dry-run]` | Build a wheel in `dist/` (never includes `.env`) | +| `nitrostack-py register --name --file main.py` | Add the server to Claude Desktop's config | + +If `nitrostack-py` is not on PATH, use `python -m nitrostack.cli.main `. + +## Do not + +- Write to stdout in a stdio server. +- Add a trailing slash to the Streamable HTTP URL in client configs (`http://localhost:3000/mcp`). +- Edit files under `vendor/nitrostack`: that is the SDK copy the project runs, replaced wholesale when re-vendored. diff --git a/skills/ui-widgets/SKILL.md b/skills/ui-widgets/SKILL.md index 3a9ac41..d6d1f67 100644 --- a/skills/ui-widgets/SKILL.md +++ b/skills/ui-widgets/SKILL.md @@ -10,40 +10,53 @@ Attaching UI to a Python tool result (Calculator, Pizzaz, flight-booking). Not ` ## Associate a widget with a tool ```python -from nitrostack import tool, widget, WidgetOptions, WidgetCsp +from pydantic import BaseModel +from nitrostack import injectable, tool, widget, WidgetOptions, WidgetCsp, ExecutionContext -@tool(name="calculate", description="...", input_schema=CalculateInput) -@widget("calculator-result") -async def calculate(self, input: CalculateInput, context: ExecutionContext) -> dict: - return {"result": ..., "expression": ...} -``` +class CalculateInput(BaseModel): + a: float + b: float -Or with CSP / border: +class MapInput(BaseModel): + city: str -```python -@widget(WidgetOptions( - route="pizza-map", - prefers_border=True, - csp=WidgetCsp( - resource_domains=["https://api.mapbox.com", ...], - connect_domains=["https://api.mapbox.com"], - ), -)) -``` +@injectable() +class WidgetTools: + @tool(name="calculate", description="Add two numbers", input_schema=CalculateInput) + @widget("calculator-result") + async def calculate(self, input: CalculateInput, context: ExecutionContext) -> dict: + return {"result": input.a + input.b, "expression": f"{input.a} + {input.b}"} -`nitrostack-py init` and `ensure_python_widgets` write `widgets/out/{route}.html` plus `widgets/preview.html`. Routes are scraped from `@widget("...")` and `WidgetOptions(route="...")`. + # Object form, for CSP and border options + @tool(name="show_pizza_map", description="Pizza shops on a map", input_schema=MapInput) + @widget(WidgetOptions( + route="pizza-map", + prefers_border=True, + csp=WidgetCsp( + resource_domains=["https://api.mapbox.com"], + connect_domains=["https://api.mapbox.com"], + ), + )) + async def show_pizza_map(self, input: MapInput, context: ExecutionContext) -> dict: + return {"city": input.city, "shops": []} +``` Return JSON/`dict` from the tool; the widget HTML reads `structuredContent` / injected tool data. Do not return a React tree from Python. +The SDK registers `ui://widget/.html` as an MCP resource and adds the widget `_meta` to `tools/list` and `tools/call` results. + ## Files -- `widgets/out/.html` — page the MCP host loads (`ui://widget/.html`). -- `widgets/preview.html` — static preview. -- Optional `src/widgets/` Next app in some templates for local widget `npm run dev` (port `WIDGETS_DEV_PORT`, default 3001). MCP server port is `PORT` (default 3000). +- `widgets/out/.html`: the page the MCP host loads. `nitrostack-py init` creates a missing one for every route it finds in `@widget("...")` and `WidgetOptions(route="...")`. The SDK also looks in `src/widgets/out` and `dist/widgets/out`. +- `widgets/preview.html`: a static helper, not the live result. Live preview: `http://localhost:3000/widgets/preview`. +- Optional `src/widgets/` Next.js app: when present, `init` runs `npm install` there and `nitrostack-py dev` starts it on `WIDGETS_DEV_PORT` (default 3001). The bundled templates ship plain HTML in `widgets/out` instead. The MCP server port is `PORT` (default 3000). +- Widget pages are self-contained: plain CSS (inline styles, a `