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 796d0e6..2bbb662 100644 --- a/skills/auth-security/SKILL.md +++ b/skills/auth-security/SKILL.md @@ -1,10 +1,21 @@ --- name: nitrostack-python-auth-security -description: Best practices for implementing JWT, API Keys, OAuth 2.1, RBAC, 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 -Use this skill when configuring security modules, implementing user authentication, protecting Python tools/resources with guards, verifying JWTs or API keys, or securing endpoints with OAuth 2.1 and PKCE. + +Protecting Python NitroStack tools with 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 | --- @@ -13,6 +24,7 @@ Use this skill when configuring security modules, implementing user authenticati Use `JWTModule` for stateless token-based authentication. ### Register `JWTModule` on `AppModule`: + ```python from nitrostack import module, ConfigModule, JWTModule import os @@ -22,8 +34,8 @@ import os imports=[ ConfigModule.for_root(env_file_path=".env"), JWTModule.for_root( - secret_env_var="JWT_SECRET", # Reads secret from os.environ["JWT_SECRET"] - expires_in="24h", # Token expiration window (e.g., '1h', '24h', '7d') + secret_env_var="JWT_SECRET", + expires_in="24h", audience=os.environ.get("TOKEN_AUDIENCE"), issuer=os.environ.get("TOKEN_ISSUER"), ), @@ -34,8 +46,9 @@ class AppModule: pass ``` -### Protect Tools with `JwtGuard`: -`JwtGuard` automatically extracts the `Bearer ` from metadata/headers, verifies the signature, and populates `context.auth` (`AuthContext` containing `subject`, `scopes`, `client_id`, `claims`, etc.). +### Protect tools with `JwtGuard`: + +`JwtGuard` extracts `Bearer ` from metadata/headers, verifies the signature, and populates `context.auth`. ```python from nitrostack import injectable, tool, use_guards, JwtGuard, ExecutionContext @@ -60,11 +73,12 @@ class UserTools: --- -## 2. API Key Authentication +## 2. API key authentication Use `ApiKeyModule` for service-to-service validation. ### Register `ApiKeyModule`: + ```python from nitrostack import module, ApiKeyModule @@ -72,9 +86,9 @@ from nitrostack import module, ApiKeyModule name="app", imports=[ ApiKeyModule.for_root( - keys_env_prefix="API_KEY", # Reads API_KEY, API_KEY_1, API_KEY_2, etc. - header_name="x-api-key", # Header to inspect in request metadata - hashed=False, # Set True if stored keys are SHA-256 hashes + keys_env_prefix="API_KEY", + header_name="x-api-key", + hashed=False, ), SystemModule, ], @@ -83,8 +97,9 @@ class AppModule: pass ``` -### Protect Tools with `ApiKeyGuard`: -`ApiKeyGuard` checks for the API key in `context.metadata["x-api-key"]` or `context.metadata["headers"]["x-api-key"]` and validates it against `ApiKeyService`. +### Protect tools with `ApiKeyGuard`: + +`ApiKeyGuard` checks `context.metadata["x-api-key"]` or `context.metadata["headers"]["x-api-key"]` and validates against `ApiKeyService`. ```python from nitrostack import injectable, tool, use_guards, ApiKeyGuard, ExecutionContext @@ -107,9 +122,9 @@ class SystemTools: --- -## 3. Role-Based Access Control (RBAC) & Custom Guards +## 3. RBAC and custom guards -Custom guards implement the `Guard` protocol with `async def can_activate(self, context: ExecutionContext) -> bool`. Chain authentication guards before authorization guards using `@use_guards(...)`. +Custom guards implement `async def can_activate(self, context: ExecutionContext) -> bool`. Chain authentication guards before authorization guards with `@use_guards(...)`. ```python from nitrostack import injectable, tool, use_guards, JwtGuard, ExecutionContext @@ -118,7 +133,6 @@ from pydantic import BaseModel @injectable() class AdminGuard: async def can_activate(self, context: ExecutionContext) -> bool: - # Requires JwtGuard / OAuthGuard to have populated context.auth first if not context.auth: return False return context.auth.claims.get("role") == "admin" @@ -133,21 +147,24 @@ class AdminTools: description="Wipes database. Admin only.", input_schema=ResetDbInput, ) - @use_guards(JwtGuard, AdminGuard) # First authenticate with JWT, then check Admin role + @use_guards(JwtGuard, AdminGuard) async def reset_database(self, input: ResetDbInput, context: ExecutionContext) -> dict: return {"success": True, "initiated_by": context.auth.subject} ``` --- -## 4. OAuth 2.1 & Scopes +## 4. OAuth 2.1 and scopes -Use `OAuthModule` for OAuth 2.1 protected resource metadata discovery and token introspection. +Use `OAuthModule` for protected-resource metadata and token introspection (or `jwks_uri=...` for local JWT verification). + +The `python-oauth` template wires `OAuthModule.for_root(...)` next to `ConfigModule`. Values come from `.env` (`RESOURCE_URI`, `AUTH_SERVER_URL`, introspection client, audience, issuer). Follow `OAUTH_SETUP.md` in the generated project. -### Wire `OAuthModule` on `AppModule`: ```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", @@ -165,52 +182,66 @@ import os ), FlightsModule, ], + providers=[SystemHealthCheck], ) class AppModule: pass ``` -### Scope Protection with `require_scopes` or Custom Scope Guards: +### Protect tools with scopes: + ```python -from nitrostack import injectable, tool, use_guards, OAuthGuard, require_scopes, ExecutionContext 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 -class FlightSearchInput(BaseModel): +class SearchInput(BaseModel): origin: str destination: str @injectable() -class FlightTools: - @tool( - name="search_flights", - description="Search available flights", - input_schema=FlightSearchInput, - ) +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("read") - async def search_flights(self, input: FlightSearchInput, context: ExecutionContext) -> dict: - return {"flights": []} + @require_scopes("admin") + async def cancel_order(self, input: SearchInput, context: ExecutionContext) -> dict: + return {"cancelled": True} ``` -### Behavior of `OAuthGuard`: -- Reads Bearer token from `context.metadata` (`authorization`, `headers`, or `_oauth`). -- If **no token** and `OAUTH_REQUIRED` is not `true` → allows the call (useful for local development in Studio/Inspector). -- If **no token** and `OAUTH_REQUIRED=true` → raises `PermissionError`. -- If token present → validates token via `OAuthService.introspect_token` and populates `context.auth` (`subject`, `scopes`, `aud` as a normalized `list`, `claims`). +### `OAuthGuard` behavior + +- 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 via `OAuthService`, then populate `context.auth` (`aud` is a normalized `list`, not a string). + +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. --- -## 5. Scope & PKCE Utility Functions +## 5. Scope and PKCE helpers -NitroStack provides built-in helper functions in `nitrostack`: -- `has_scope(context.auth, "read")` -> `bool` -- `has_all_scopes(context.auth, ["read", "write"])` -> `bool` -- `has_any_scope(context.auth, ["admin", "write"])` -> `bool` +Built-in helpers in `nitrostack`: + +- `has_scope(context.auth, "read")` → `bool` +- `has_all_scopes(context.auth, ["read", "write"])` → `bool` +- `has_any_scope(context.auth, ["admin", "write"])` → `bool` - `generate_code_verifier()`, `generate_code_challenge(verifier)`, `verify_pkce(verifier, challenge)` +- `generate_pkce_params()` for a full PKCE parameter set; `generate_www_authenticate_header` for custom flows --- -## Do Not -- Do not copy TypeScript `@nitrostack/core` decorator syntax or Passport strategies. -- Do not assume `context.auth.aud` is a string; NitroStack normalizes `aud` to a `list` to ensure safe membership checks (`"aud" in context.auth.aud`). -- Do not commit `.env` or hardcode secrets into source files. +## Do not + +- Copy TypeScript `@nitrostack/core` decorator syntax or Passport strategies. +- Assume `context.auth.aud` is a string; it is normalized to a `list` (`"aud" in context.auth.aud`). +- Use `ApiKeyGuard` without `ApiKeyModule.for_root()` or an `API_KEY` variable: it then allows every call. +- Commit `.env` or hardcode secrets into source files. diff --git a/skills/config-environment/SKILL.md b/skills/config-environment/SKILL.md new file mode 100644 index 0000000..c79fc89 --- /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, then run `nitrostack-py upgrade` and `nitrostack-py install`. If `upgrade` fails with "Refusing to write an invalid pyproject.toml", the CLI is older than 0.4.2: run `uvx --from nitrostack nitrostack-py upgrade`. + +## 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 7b9e531..065eb95 100644 --- a/skills/mcp-app-architecture/SKILL.md +++ b/skills/mcp-app-architecture/SKILL.md @@ -1,18 +1,16 @@ --- name: nitrostack-python-mcp-app-architecture -description: Bootstrapping a NitroStack Python MCP server — AppModule, modules, DI, events, lifecycles, and McpApplicationFactory. +description: Bootstrapping a NitroStack Python MCP server — AppModule, modules, DI, and McpApplicationFactory. --- ## When to Use -Use this skill when creating, structuring, or refactoring a NitroStack **Python** MCP server: configuring the root module, organizing feature modules, managing dependencies via DI, emitting/handling events, registering health checks, or configuring server transports. ---- +Use this skill when creating or changing a NitroStack **Python** MCP server: root module, feature modules, providers, or process startup. Do not use TypeScript `@McpApp`, `@Module`, or Zod. -## 1. Bootstrapping an Application +## Bootstrapping -NitroStack Python applications can be started using `McpApplicationFactory.create(...)` with the root module or `@mcp_app` class. +Generated apps start from `main.py` with `McpApplicationFactory.create` and the root `AppModule`. There is no required `@mcp_app` class in the starter templates. -### Standard Entrypoint (`main.py`): ```python import asyncio from nitrostack import McpApplicationFactory @@ -26,7 +24,8 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### With ServerConfig & Transport Options (`@mcp_app`): +### With `ServerConfig` and transport in code (`@mcp_app`): + ```python import asyncio from nitrostack import mcp_app, McpApplicationFactory, ServerConfig @@ -37,9 +36,9 @@ from app_module import AppModule server=ServerConfig( name="my-mcp-server", version="1.0.0", - transport_type="stdio", # Options: "stdio", "http", or "dual" (runs stdio + http together) - max_sessions=100, # Max concurrent HTTP sessions - session_timeout_ms=1800000, # 30 min idle timeout + transport_type="stdio", # "stdio", "http", or "dual" + max_sessions=100, + session_timeout_ms=1800000, ), ) class App: @@ -53,11 +52,11 @@ if __name__ == "__main__": asyncio.run(main()) ``` ---- +Transport flags and env overrides are documented in the transports-cli skill. -## 2. Root AppModule & Configuration +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`. -The root module aggregates imported feature modules, global services, and configuration. +## Root AppModule ```python from nitrostack import module, ConfigModule @@ -76,15 +75,23 @@ 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`, `ApiKeyModule`, `JWTModule`, `OAuthModule`). + +## Feature modules -## 3. Feature Modules & Separation of Concerns +One directory per feature under `modules/`. -Modules organize tools and services into isolated domain boundaries. -- **`controllers`**: Classes containing `@tool`, `@resource`, or `@prompt` definitions. -- **`providers`**: Services, repositories, or helpers managed by the DI container. -- **`imports`**: Other modules whose exported providers are needed here. -- **`exports`**: Providers from this module made available to other modules that import it. +- **controllers**: classes with `@tool`, `@resource`, or `@prompt`. +- **providers**: services managed by the DI container. +- **imports**: other modules whose exported providers are needed here. +- **exports**: providers made available to modules that import this one. + +``` +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 @@ -101,102 +108,36 @@ class CalculatorModule: pass ``` ---- +Scaffold with `nitrostack-py generate module ` and `nitrostack-py generate service ` (see transports-cli skill for other generators). -## 4. Dependency Injection (DI) +## Dependency injection -Mark classes with `@injectable()` or `@injectable(deps=[...])`. Constructor parameters must match the declared dependencies in `deps`. +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 DatabaseService: - def query(self, sql: str) -> list: +class PizzazService: + def list_shops(self) -> list: return [] -@injectable(deps=[DatabaseService]) -class UserService: - def __init__(self, db: DatabaseService): - self.db = db - -@injectable(deps=[UserService]) -class UserTools: - def __init__(self, user_service: UserService): - self.user_service = user_service +@injectable(deps=[PizzazService]) +class PizzazTools: + def __init__(self, service: PizzazService): + self.service = service ``` -> [!NOTE] -> The DI container automatically resolves and instantiates singletons for all controllers and providers registered in active modules. Do not instantiate `@injectable` services manually inside tool methods. +Settings come from `ConfigService` (config-environment skill). ---- - -## 5. Event System (`EventEmitter` and `@on_event`) +## Health checks and events -NitroStack includes an asynchronous internal event system to decouple services and modules. +Register a provider with `@health_check("system")` on a method that returns a bool (see `health/system_health.py`). Caching, rate limits, long-running tasks, and the event bus are in the runtime-features skill. -### Emitting Events: -```python -from nitrostack import injectable, EventEmitter - -@injectable() -class OrderService: - async def place_order(self, order_id: str, amount: float): - # Process order logic... - - # Emit event to all registered listeners - await EventEmitter.get_instance().emit( - "order.placed", - {"order_id": order_id, "amount": amount} - ) -``` - -### Listening to Events with `@on_event`: -Decorate any method inside an `@injectable` provider or controller with `@on_event("event_name")`: - -```python -from nitrostack import injectable, on_event - -@injectable() -class NotificationService: - @on_event("order.placed") - async def on_order_placed(self, payload: dict): - order_id = payload.get("order_id") - print(f"Sending confirmation email for order {order_id}") -``` - ---- - -## 6. Health Checks (`@health_check`) - -Register health checks on providers or controllers to expose system status on the `/mcp/health` endpoint: - -```python -from nitrostack import injectable, health_check - -@injectable() -class SystemHealthCheck: - @health_check("database") - async def check_database(self) -> bool: - # Return True for healthy, False for unhealthy - return True -``` - ---- - -## 7. CLI Lifecycle (`nitrostack-py`) - -- `nitrostack-py init [name] [--template python-starter|python-pizzaz|python-oauth]` -- `nitrostack-py dev [--port 3000] [--widget 3001]` (hot reload) -- `nitrostack-py start` (production runner) -- `nitrostack-py generate tool|module|guard|pipe|interceptor|filter|service ` -- `nitrostack-py pack` (builds deployable wheel in `dist/`) -- `nitrostack-py validate` (lints imports, modules, and dependencies) -- `nitrostack-py register --name my-server --file app.py` (registers with Claude Desktop) - ---- +## Do not -## Do Not -- Do not import `@nitrostack/core` or use TypeScript `@Module` syntax. -- Do not define `@tool` methods directly on module configuration classes. Put them on controller classes. -- Do not instantiate services manually with `SomeService()`; use constructor DI via `@injectable(deps=[...])`. +- 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. +- Instantiate services manually with `SomeService()`; use constructor DI via `@injectable(deps=[...])`. diff --git a/skills/middleware-pipeline/SKILL.md b/skills/middleware-pipeline/SKILL.md index dd5a4b2..6e8c94d 100644 --- a/skills/middleware-pipeline/SKILL.md +++ b/skills/middleware-pipeline/SKILL.md @@ -1,56 +1,49 @@ --- name: nitrostack-python-middleware-pipeline -description: Best practices for implementing and applying Guards, Interceptors, Middleware, Pipes, and Exception Filters in NitroStack Python MCP servers. +description: Guards, middleware, interceptors, pipes, and exception filters on NitroStack Python tools. --- ## When to Use -Use this skill when implementing request authorization, input transformation, execution timing, error handling, or logging on NitroStack Python tools, resources, and prompts. ---- +Authorizing or transforming a tool/resource/prompt call in the **Python** SDK (`nitrostack.core.pipeline`). -## Pipeline Execution Order +## Pipeline execution order -When an MCP client invokes a tool handler, NitroStack executes the pipeline in the following sequence: -1. **Guards** (`@use_guards`): Verify authentication/authorization (`can_activate`). -2. **Pipes** (`@use_pipes`): Transform/validate raw inputs (`transform`). -3. **Middleware** (`@use_middleware`): Wrap execution before/after handler (`use`). -4. **Interceptors** (`@use_interceptors`): Intercept or transform results (`intercept`). -5. **Handler**: The actual `@tool` async method execution. -6. **Exception Filters** (`@use_filters`): Catch and map any uncaught exceptions into user-friendly responses (`catch`). +When a client invokes a tool, NitroStack runs: ---- +1. **Guards** (`@use_guards`): `can_activate` — `False` rejects with "Access denied by guard". +2. **Pipes** (`@use_pipes`): `transform` on tool input. +3. **Middleware** (`@use_middleware`): `use` wraps execution. +4. **Interceptors** (`@use_interceptors`): `intercept` around the handler. +5. **Handler**: the `@tool` method. +6. **Exception filters** (`@use_filters`): `catch` maps uncaught errors to a return value. -## 1. Guards (`Guard` and `@use_guards`) +| Stage | Method | Attach with | +|---|---|---| +| Guard | `can_activate(context) -> bool` | `@use_guards(...)` | +| Pipe | `transform(value, metadata: PipeMetadata)` | `@use_pipes(...)` | +| Middleware | `use(context, next_fn)` | `@use_middleware(...)` | +| Interceptor | `intercept(context, next_fn)` | `@use_interceptors(...)` | +| Exception filter | `catch(error, context)` | `@use_filters(...)` | -Guards control access before reaching pipes or handlers. +`PipeMetadata` (`param_name`, `param_type`) is imported from `nitrostack.core.pipeline`. -### Protocol: -```python -from nitrostack import ExecutionContext +--- -class Guard: - async def can_activate(self, context: ExecutionContext) -> bool: - ... -``` +## 1. Guards -### Example: ```python -from nitrostack import injectable, ExecutionContext +from nitrostack import injectable, tool, use_guards, ExecutionContext +from pydantic import BaseModel + +class PurgeLogsInput(BaseModel): + before_date: str @injectable() class RolesGuard: async def can_activate(self, context: ExecutionContext) -> bool: user_roles = context.auth.claims.get("roles", []) if context.auth else [] return "admin" in user_roles -``` - -### Usage on Tools: -```python -from nitrostack import injectable, tool, use_guards, ExecutionContext -from pydantic import BaseModel - -class PurgeLogsInput(BaseModel): - before_date: str @injectable() class LogTools: @@ -60,23 +53,12 @@ class LogTools: return {"purged": True} ``` ---- - -## 2. Interceptors (`Interceptor` and `@use_interceptors`) - -Interceptors wrap handler execution to measure duration, modify results, or log operations. +A guard factory (a function returning a guard class) is fine when the guard needs parameters; see `create_scope_guard` in the oauth template. -### Protocol: -```python -from typing import Callable, Any -from nitrostack import ExecutionContext +--- -class Interceptor: - async def intercept(self, context: ExecutionContext, next_fn: Callable[[], Any]) -> Any: - ... -``` +## 2. Interceptors -### Example: ```python import time from nitrostack import injectable, ExecutionContext @@ -88,27 +70,13 @@ class TimingInterceptor: result = await next_fn() duration_ms = (time.time() - start) * 1000 context.logger.info(f"Tool {context.tool_name} executed in {duration_ms:.2f}ms") - if isinstance(result, dict): - result["_meta"] = {"duration_ms": duration_ms} return result ``` --- -## 3. Exception Filters (`ExceptionFilter` and `@use_filters`) - -Exception filters catch uncaught errors thrown anywhere in the execution chain and format clean error responses. +## 3. Exception filters -### Protocol: -```python -from nitrostack import ExecutionContext - -class ExceptionFilter: - async def catch(self, error: Exception, context: ExecutionContext) -> Any: - ... -``` - -### Example: ```python from nitrostack import injectable, ExecutionContext @@ -116,30 +84,13 @@ from nitrostack import injectable, ExecutionContext class CustomExceptionFilter: async def catch(self, error: Exception, context: ExecutionContext) -> dict: context.logger.error(f"Captured exception: {error}") - return { - "error": True, - "message": str(error), - "tool": context.tool_name, - } + return {"error": True, "message": str(error), "tool": context.tool_name} ``` --- -## 4. Middleware (`Middleware` and `@use_middleware`) - -Middleware executes before and after the handler by calling `await next_fn()`. - -### Protocol: -```python -from typing import Callable, Any -from nitrostack import ExecutionContext - -class Middleware: - async def use(self, context: ExecutionContext, next_fn: Callable[[], Any]) -> Any: - ... -``` +## 4. Middleware -### Example: ```python from nitrostack import injectable, ExecutionContext @@ -158,22 +109,10 @@ class LoggingMiddleware: --- -## 5. Pipes (`Pipe` and `@use_pipes`) +## 5. Pipes -Pipes transform or validate tool input arguments before they reach the handler. - -### Protocol: ```python from typing import Any -from nitrostack.core.pipeline import PipeMetadata - -class Pipe: - async def transform(self, value: Any, metadata: PipeMetadata) -> Any: - ... -``` - -### Example: -```python from nitrostack import injectable from nitrostack.core.pipeline import PipeMetadata @@ -182,62 +121,60 @@ class TrimStringPipe: async def transform(self, value: Any, metadata: PipeMetadata) -> Any: if isinstance(value, str): return value.strip() - if hasattr(value, "__dict__"): - for k, v in value.__dict__.items(): - if isinstance(v, str): - setattr(value, k, v.strip()) return value ``` --- -## Stacking Multiple Pipeline Decorators +## Stacking decorators + +Decorator order on the method (bottom = runs first): `@tool`, then `@use_guards`, `@use_pipes`, `@use_middleware`, `@use_interceptors`, `@use_filters`. ```python -from nitrostack import ( - injectable, - tool, - use_guards, - use_pipes, - use_middleware, - use_interceptors, - use_filters, - ExecutionContext, -) +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 SecureOperationTools: - @tool(name="execute_task", description="Executes sensitive task", input_schema=TaskInput) - @use_guards(RolesGuard) - @use_pipes(TrimStringPipe) - @use_middleware(LoggingMiddleware) +class WeatherTools: + @tool(name="get_forecast", description="Forecast for a city", input_schema=LookupInput) + @use_guards(OfficeHoursGuard) @use_interceptors(TimingInterceptor) - @use_filters(CustomExceptionFilter) - async def execute_task(self, input: TaskInput, context: ExecutionContext) -> dict: - return {"status": "completed"} + @use_filters(FriendlyErrors) + async def get_forecast(self, input: LookupInput, context: ExecutionContext) -> dict: + return {"city": input.city, "forecast": "sunny"} ``` ---- +Scaffold with `nitrostack-py generate guard|pipe|interceptor|filter ` (writes `guards/`, `pipes/`, `interceptors/`, `filters/`). -## Built-in Guards -- **`ApiKeyGuard`**: Validates `x-api-key` metadata/headers with `ApiKeyService`. -- **`JwtGuard`**: Validates Bearer JWTs and populates `context.auth`. -- **`OAuthGuard`**: Introspects OAuth 2.1 access tokens and populates `context.auth`. +## Built-in guards ---- +- `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. -## CLI Generators -Generate boilerplate classes matching the Python SDK signatures: -```bash -nitrostack-py generate guard AdminGuard -nitrostack-py generate pipe SanitizeInput -nitrostack-py generate interceptor Metrics -nitrostack-py generate filter GlobalException -``` +Read tokens and request data from `context.metadata` (`authorization`, `headers`, `_oauth`). There are no Express/Nest request objects. ---- +## Do not -## Do Not -- Do not use TypeScript NestJS decorator names (`@UseGuards()` in camelCase). Use snake_case `use_guards`, `use_middleware`, etc. -- Do not forget `await next_fn()` in middleware and interceptors. -- Do not access raw HTTP `Request` objects; use `context.metadata` and `context.auth`. +- Use TypeScript `UseGuards()` / Nest middleware naming; use snake_case `use_guards`, `use_middleware`, etc. +- Forget `await next_fn()` in middleware and interceptors. +- 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..13aaa97 --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,56 @@ +--- +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_calculator(): + async def run(): + harness = await NitroTestingModule.create(AppModule) + result = await harness.call_tool("calculate", {"operation": "add", "a": 5, "b": 3}) + assert result["result"] == 8 + converted = await harness.call_tool( + "convert_temperature", {"value": 100, "from_unit": "celsius", "to_unit": "fahrenheit"} + ) + assert converted["result"] == 212 + assert "Cannot divide by zero" in str( + await harness.call_tool("calculate", {"operation": "divide", "a": 1, "b": 0}) + ) + + DIContainer.reset() + asyncio.run(run()) +``` + +This tests the `python-starter` calculator. `read_resource(uri)` and `get_prompt(name, args)` work the same way for resources and prompts. + +- `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 651bb73..d69b949 100644 --- a/skills/tools-resources-prompts/SKILL.md +++ b/skills/tools-resources-prompts/SKILL.md @@ -1,158 +1,119 @@ --- name: nitrostack-python-tools-resources-prompts -description: Guidelines and patterns for defining Tools, Resources, Prompts, Caching, Rate Limiting, Tasks, and File Uploads in NitroStack Python MCP servers. +description: Define MCP tools, resources, and prompts in NitroStack Python with Pydantic schemas and ExecutionContext. --- ## When to Use -Use this skill when defining, validating, or optimizing Tools (`@tool`), Resources (`@resource`), and Prompts (`@prompt`) on a NitroStack Python MCP server using Pydantic schemas, execution context, caching, rate limiting, and background tasks. ---- +Defining, editing, or validating tools, resources, or prompts on a **Python** NitroStack server. Use Pydantic `BaseModel`, not Zod. Caching, rate limits, and background tasks are in the runtime-features skill. -## 1. Defining Tools (`@tool`) +## Tools -An MCP tool exposes a callable function to AI clients. Decorate async methods on an `@injectable` controller class with `@tool`. +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`. -### Tool Options: -* `name` (required): Unique snake_case string identifier. -* `description` (required): Clear description guiding the LLM when to invoke the tool. -* `input_schema` (required): Pydantic `BaseModel` class defining input schema. -* `output_schema` (optional): Pydantic model validating output payload structure. -* `annotations` (optional): `ToolAnnotations(destructive_hint=..., read_only_hint=..., idempotent_hint=..., open_world_hint=...)`. -* `task_support` (optional): `"forbidden"`, `"optional"`, or `"required"` for long-running task processing. -* `visibility` (optional): `"visible"` or `"hidden"`. +`@tool` options: `name`, `description`, `input_schema` (required), plus optional `title`, `output_schema`, `annotations` (`ToolAnnotations`), `task_support`, `visibility`, `examples` (`ToolExamples`), `invocation`, `metadata`. -### Example: ```python -from nitrostack import injectable, tool, initial_tool, ToolAnnotations, 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 WeatherInput(BaseModel): - city: str = Field(description="City name, e.g., San Francisco") - unit: Literal["celsius", "fahrenheit"] = Field(default="celsius", description="Temperature unit") +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 WeatherOutput(BaseModel): - temperature: float - condition: str +class CalculateOutput(BaseModel): + result: float + expression: str @injectable() -class WeatherTools: +class CalculatorTools: @tool( - name="get_current_weather", - description="Get current weather conditions for a given city.", - input_schema=WeatherInput, - output_schema=WeatherOutput, - annotations=ToolAnnotations(read_only_hint=True), + name="calculate", + title="Calculate", + description="Perform basic arithmetic calculations", + input_schema=CalculateInput, + output_schema=CalculateOutput, + 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"}, + ), ) - @initial_tool # Optional: Auto-invoked when client initializes/connects - async def get_weather(self, input: WeatherInput, context: ExecutionContext) -> dict: - context.logger.info(f"Fetching weather for {input.city}") - return {"temperature": 21.5, "condition": "Sunny"} + @widget("calculator-result") + async def calculate(self, input: CalculateInput, context: ExecutionContext) -> dict: + context.logger.info(f"{input.operation} {input.a} {input.b}") + result = input.a + input.b + return {"result": result, "expression": f"{input.a:g} + {input.b:g} = {result:g}"} ``` ---- - -## 2. Tool Policies: Caching (`@cache`) & Rate Limiting (`@rate_limit`) - -Control performance and throttle requests with method decorators from `nitrostack`. - -### Caching (`@cache`): -Caches method outputs for a given TTL in seconds. Skips `ExecutionContext` when building cache keys. -```python -from nitrostack import injectable, tool, cache, ExecutionContext -from pydantic import BaseModel +- 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. -class StatusInput(BaseModel): - system_id: str +## Resources -@injectable() -class DiagnosticTools: - @tool(name="get_system_status", description="Get diagnostic metrics", input_schema=StatusInput) - @cache(ttl=60) # Caches result for 60 seconds - async def get_status(self, input: StatusInput, context: ExecutionContext) -> dict: - return {"system_id": input.system_id, "healthy": True} -``` +Resource handlers take `(self, context)`. List the class on the feature module `controllers`. -### Rate Limiting (`@rate_limit`): -Restricts tool execution frequency to a maximum number of calls within a time window (in seconds). ```python -from nitrostack import injectable, tool, rate_limit, ExecutionContext -from pydantic import BaseModel - -class DiagnosticInput(BaseModel): - deep_scan: bool = False +from nitrostack import injectable, resource, ResourceAnnotations, ExecutionContext @injectable() -class MaintenanceTools: - @tool(name="run_diagnostics", description="Runs heavy diagnostics", input_schema=DiagnosticInput) - @rate_limit(max=5, window=60) # Maximum 5 calls per 60 seconds - async def run_diagnostics(self, input: DiagnosticInput, context: ExecutionContext) -> dict: - return {"scan_complete": True} +class FlightResources: + @resource( + uri="flight://popular-routes", + name="Popular Flight Routes", + description="Information about popular routes and pricing", + mime_type="application/json", + annotations=ResourceAnnotations(audience=["assistant"], priority=1.0), + ) + async def popular_routes(self, context: ExecutionContext) -> dict: + return {"routes": [{"from": "JFK", "to": "LHR"}]} ``` ---- - -## 3. Asynchronous Background Tasks +## Prompts -For long-running tools, set `task_support="optional"` or `"required"` and use `context.task` for progress reporting and cancellation checks. +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 -import asyncio -from nitrostack import injectable, tool, ExecutionContext -from pydantic import BaseModel, Field - -class DataProcessingInput(BaseModel): - dataset_url: str = Field(description="URL of dataset to process") +from typing import List +from nitrostack import injectable, prompt, PromptArgument, PromptMessage, ExecutionContext @injectable() -class DataPipelineTools: - @tool( - name="process_large_dataset", - description="Processes large dataset asynchronously", - input_schema=DataProcessingInput, - task_support="optional", # or "required" +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 process_dataset(self, input: DataProcessingInput, context: ExecutionContext) -> dict: - total_steps = 5 - for step in range(1, total_steps + 1): - # Check if client cancelled the task - if context.task: - context.task.throw_if_cancelled() - context.task.update_progress(f"Processing step {step}/{total_steps}...") - - await asyncio.sleep(1) # Simulate processing chunk - - return {"status": "completed", "dataset": input.dataset_url} + 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.")] ``` ---- - -## 4. Handling File Uploads in Tools (Base64) - -NitroStack tools receive client file uploads as base64-encoded strings within Pydantic schemas. +## File uploads in tools (base64) -### 1. Define Input Schema: -```python -from pydantic import BaseModel, Field - -class FileUploadInput(BaseModel): - file_name: str = Field(description="Original file name, e.g., document.pdf") - file_type: str = Field(description="MIME type, e.g., application/pdf") - file_content: str = Field(description="Base64 encoded file content") -``` +Clients often send uploads as base64 strings in the tool input schema: -### 2. Universal Base64 Decoder & Secure File Storage: ```python import base64 import os import re from pathlib import Path +from pydantic import BaseModel, Field from nitrostack import injectable, tool, ExecutionContext +class FileUploadInput(BaseModel): + file_name: str = Field(description="Original file name") + file_content: str = Field(description="Base64 or data-URL encoded content") + UPLOAD_DIR = Path(os.getcwd()) / "uploads" def decode_base64_file(content: str) -> bytes: - """Decodes Data URL or raw base64 string into bytes.""" data_url_match = re.match(r"^data:[^;]+;base64,(.+)$", content) raw_data = data_url_match.group(1) if data_url_match else content return base64.b64decode(raw_data) @@ -162,75 +123,19 @@ class FileTools: @tool(name="upload_document", description="Save uploaded file securely", input_schema=FileUploadInput) async def upload_document(self, input: FileUploadInput, context: ExecutionContext) -> dict: UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - - # Prevent directory traversal attacks safe_filename = Path(input.file_name).name destination = (UPLOAD_DIR / safe_filename).resolve() if not str(destination).startswith(str(UPLOAD_DIR.resolve())): raise ValueError("Invalid file path (path traversal detected).") - file_bytes = decode_base64_file(input.file_content) destination.write_bytes(file_bytes) - - context.logger.info(f"Saved file {safe_filename} ({len(file_bytes)} bytes)") return {"success": True, "saved_path": str(destination), "bytes": len(file_bytes)} ``` ---- - -## 5. Defining Resources (`@resource`) - -Resources expose data or files at custom URI schemes that AI clients can inspect. - -```python -from nitrostack import injectable, resource, ResourceAnnotations, ExecutionContext - -@injectable() -class ServerResources: - @resource( - uri="server://config", - name="Server Configuration", - description="System configuration parameters", - mime_type="application/json", - annotations=ResourceAnnotations(audience=["assistant"], priority=1.0), - ) - async def get_config(self, context: ExecutionContext) -> dict: - return {"env": "production", "debug": False} -``` - ---- - -## 6. Defining Prompts (`@prompt`) - -Prompts expose parameterized prompt templates to guide LLM interactions. - -```python -from nitrostack import injectable, prompt, PromptArgument, ExecutionContext - -@injectable() -class AssistantPrompts: - @prompt( - name="code_review", - description="Review code according to security and style guidelines", - arguments=[ - PromptArgument(name="language", description="Target language (e.g. Python)", required=True), - PromptArgument(name="code", description="Source code snippet", required=True), - ], - ) - async def code_review_prompt(self, arguments: dict, context: ExecutionContext) -> list: - lang = arguments.get("language", "Python") - code = arguments.get("code", "") - return [ - { - "role": "user", - "content": f"You are a senior {lang} engineer. Review this code for bugs and security:\n\n{code}", - } - ] -``` - ---- +## Do not -## Do Not -- Do not use Zod (`z.object`) — use Pydantic `BaseModel`. -- Do not forget `context: ExecutionContext` parameter on `@tool`, `@resource`, and `@prompt` methods. -- Do not write unvalidated file paths directly to disk without path traversal checks. +- 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. +- Put `@cache` on a tool method: it cannot key on the Pydantic input. Cache a service method instead (runtime-features skill). +- Write unvalidated file paths directly to disk without path traversal checks. diff --git a/skills/transports-cli/SKILL.md b/skills/transports-cli/SKILL.md new file mode 100644 index 0000000..19cb51b --- /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]` | Move the project to another SDK release (replaces `vendor/nitrostack` from 0.4.2), 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 f37d430..c9a05fe 100644 --- a/skills/ui-widgets/SKILL.md +++ b/skills/ui-widgets/SKILL.md @@ -1,115 +1,93 @@ --- name: nitrostack-python-ui-widgets -description: Best practices for linking Python MCP tools to UI widgets, WidgetOptions, CSP, application modes, and preview. +description: Python NitroStack MCP Apps widgets — @widget, widgets/out HTML, WidgetOptions, and preview. --- ## When to Use -Use this skill when attaching UI widgets to Python MCP tool outputs (e.g. interactive cards, map views, flight cards, or forms) and configuring widget routes, CSP, and preview rendering. ---- +Attaching UI to a Python tool result (Calculator, Pizzaz, flight-booking). Not `@nitrostack/ui` npm widgets. -## 1. Associating a Widget with a Tool (`@widget`) +## Associate a widget with a tool -Decorate any `@tool` method with `@widget("route-name")` or with `@widget(WidgetOptions(...))` to bind frontend HTML/React components to tool results. +Decorate any `@tool` method with `@widget("route-name")` or `@widget(WidgetOptions(...))`. -### Simple Route Binding: ```python -from nitrostack import injectable, tool, widget, ExecutionContext from pydantic import BaseModel - -class ProductInput(BaseModel): - product_id: str - -@injectable() -class StoreTools: - @tool(name="get_product", description="Fetch product card", input_schema=ProductInput) - @widget("product-card") # Maps to widgets/out/product-card.html - async def get_product(self, input: ProductInput, context: ExecutionContext) -> dict: - return { - "name": "Super Nitro Coffee", - "price": 4.99, - "stock": 42, - } -``` - -### Advanced WidgetOptions with CSP & Border: -```python from nitrostack import injectable, tool, widget, WidgetOptions, WidgetCsp, ExecutionContext -from pydantic import BaseModel -class LocationInput(BaseModel): +class CalculateInput(BaseModel): + a: float + b: float + +class MapInput(BaseModel): city: str @injectable() -class MapTools: - @tool(name="show_store_map", description="Show interactive map of stores", input_schema=LocationInput) - @widget( - WidgetOptions( - route="store-map", - prefers_border=True, - csp=WidgetCsp( - resource_domains=["https://api.mapbox.com", "https://events.mapbox.com"], - connect_domains=["https://api.mapbox.com"], - ), - ) - ) - async def show_map(self, input: LocationInput, context: ExecutionContext) -> dict: - return {"city": input.city, "stores": [{"name": "Downtown", "lat": 37.77, "lng": -122.41}]} +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}"} + + @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. -## 2. Application Modes (`NITROSTACK_APP_MODE`) +The SDK registers `ui://widget/.html` as an MCP resource and adds widget `_meta` to `tools/list` and `tools/call` results. -NitroStack supports mode-gated metadata to serve OpenAI and MCP Apps clients simultaneously via the `NITROSTACK_APP_MODE` environment variable (default: `universal`): +## Application modes (`NITROSTACK_APP_MODE`) -| Mode | Tool `_meta` | Resource MIME Type | +| Mode | Tool `_meta` | Resource MIME type | |---|---|---| -| `universal` (default) | Populates both OpenAI (`openai/outputTemplate`) and MCP Apps (`_meta.ui`) | `text/html;profile=mcp-app` | -| `mcp-app` | Standard MCP Apps UI metadata (`resourceUri`, `visibility`, CSP) | `text/html;profile=mcp-app` | +| `universal` (default) | Both OpenAI (`openai/outputTemplate`) and MCP Apps (`_meta.ui`) | `text/html;profile=mcp-app` | +| `mcp-app` | MCP Apps UI metadata (`resourceUri`, visibility, CSP) | `text/html;profile=mcp-app` | | `openai` | OpenAI template metadata (`ui/template`, `outputTemplate`) | `text/html` | ---- - -## 3. Widget Files & Directory Layout +`init` sets `universal` by default so one server works with OpenAI and MCP Apps hosts. -- **`widgets/out/{route}.html`**: The production HTML bundle rendered inside the MCP host's webview. NitroStack automatically exposes this file as an MCP resource with URI `ui://widget/{route}.html`. -- **`widgets/preview.html`**: Static preview page for inspecting widget templates during development. -- **`src/widgets/`** (optional): Next.js or React frontend source app when using a multi-package setup. +## Files ---- +- `widgets/out/.html`: the page the MCP host loads. `nitrostack-py init` creates a missing one for every route in `@widget("...")` and `WidgetOptions(route="...")`. The SDK also looks in `src/widgets/out` and `dist/widgets/out`. +- `widgets/preview.html`: static helper. 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). Bundled templates ship plain HTML in `widgets/out`. MCP server port is `PORT` (default 3000). +- Widget pages are self-contained: plain CSS (inline styles, a `