diff --git a/PYTHON_SDK_SPEC.md b/PYTHON_SDK_SPEC.md deleted file mode 100644 index f87cf57..0000000 --- a/PYTHON_SDK_SPEC.md +++ /dev/null @@ -1,711 +0,0 @@ -# NitroStack Python SDK — Feature Specification - -> Derived from `@nitrostack/core` TypeScript source. -> This document defines what the Python SDK must implement, how each feature maps from TS, and what the Python-idiomatic API should look like. - ---- - -## 1. MCP Primitives - -### 1.1 Tools - -**TS source:** `decorators.ts` → `@Tool()`, `tool.ts` → `Tool` class - -Tools are the primary way an MCP server exposes functionality to AI clients. - -**TS API:** -```typescript -@Tool({ - name: 'login', - title: 'Login', - description: 'Login with email and password', - inputSchema: z.object({ email: z.string(), password: z.string() }), - outputSchema: z.object({ token: z.string() }), - annotations: { readOnlyHint: false, destructiveHint: false }, - taskSupport: 'optional', -}) -async login(input: LoginInput, context: ExecutionContext) { ... } -``` - -**Python API:** -```python -@tool( - name="login", - title="Login", - description="Login with email and password", - input_schema=LoginInput, # Pydantic model - output_schema=LoginOutput, # Pydantic model (optional) - annotations=ToolAnnotations(read_only_hint=False), - task_support="optional", -) -async def login(self, input: LoginInput, context: ExecutionContext): ... -``` - -**Fields to support:** - -| Field | Type | Notes | -|---|---|---| -| `name` | `str` | Required | -| `title` | `str` | Optional, display name | -| `description` | `str` | Required | -| `input_schema` | Pydantic model or dict | Required. Call `.model_json_schema()` for wire format | -| `output_schema` | Pydantic model or dict | Optional | -| `annotations` | `ToolAnnotations` dataclass | Optional | -| `invocation` | `ToolInvocation` dataclass | Optional, for OpenAI Apps SDK UI messages | -| `task_support` | `"forbidden" \| "optional" \| "required"` | Default: `"forbidden"` | -| `visibility` | `"visible" \| "hidden"` | Default: `"visible"`, MCP Apps mode | -| `examples` | `ToolExamples` dataclass | Optional | -| `metadata` | dict | Optional, category/tags/rate_limit | - -**`ToolAnnotations` fields (from `types.ts`):** - -| Field | Default | Meaning | -|---|---|---| -| `destructive_hint` | `True` | Tool may make destructive changes | -| `idempotent_hint` | `False` | Repeated calls have no extra effect | -| `read_only_hint` | `False` | Tool does not modify environment | -| `open_world_hint` | `True` | Tool interacts with external entities | - -**`@InitialTool()`** — marks a tool to be auto-called on client connection. Python equivalent: `@initial_tool` stacked with `@tool`. - ---- - -### 1.2 Resources - -**TS source:** `decorators.ts` → `@Resource()` - -Resources expose readable data to AI clients (files, DB records, API data, etc.). - -**TS API:** -```typescript -@Resource({ - uri: 'db://users/schema', - name: 'User Schema', - description: 'Database schema for users table', - mimeType: 'application/json', -}) -async getUserSchema(context: ExecutionContext) { ... } -``` - -**Python API:** -```python -@resource( - uri="db://users/schema", - name="User Schema", - description="Database schema for users table", - mime_type="application/json", -) -async def get_user_schema(self, context: ExecutionContext): ... -``` - -**Fields to support:** - -| Field | Type | Notes | -|---|---|---| -| `uri` | `str` | Required. Static URI or template (e.g. `notes://note/{id}`) | -| `name` | `str` | Required | -| `title` | `str` | Optional | -| `description` | `str` | Required | -| `mime_type` | `str` | Optional, e.g. `"application/json"`, `"text/plain"` | -| `size` | `int` | Optional, bytes | -| `annotations` | `ResourceAnnotations` dataclass | Optional | -| `metadata.cacheable` | `bool` | Optional | -| `metadata.cache_max_age` | `int` | Optional, seconds | - -**`ResourceAnnotations` fields:** - -| Field | Type | Meaning | -|---|---|---| -| `audience` | `list["user" \| "assistant"]` | Intended consumer | -| `priority` | `float` | 0.0–1.0 importance | -| `last_modified` | `str` | ISO 8601 timestamp | - -**Resource content return types** (discriminated union from `types.ts`): -```python -# Handler must return one of: -{"type": "text", "data": "..."} -{"type": "binary", "data": b"..."} -{"type": "json", "data": {...}} -``` - ---- - -### 1.3 Prompts - -**TS source:** `decorators.ts` → `@Prompt()` - -Prompt templates that AI clients can request to pre-fill conversation context. - -**TS API:** -```typescript -@Prompt({ - name: 'auth-help', - description: 'Help with authentication flows', - arguments: [{ name: 'issue', description: 'The auth issue', required: true }], -}) -async authHelp(args: PromptArgs, context: ExecutionContext) { ... } -``` - -**Python API:** -```python -@prompt( - name="auth-help", - description="Help with authentication flows", - arguments=[PromptArgument(name="issue", description="The auth issue", required=True)], -) -async def auth_help(self, args: dict, context: ExecutionContext): ... -``` - -**`PromptMessage` return type** — handler must return a list of: -```python -PromptMessage(role="user" | "assistant" | "system", content="...") -``` - ---- - -## 2. Execution Pipeline - -**TS source:** `tool.ts` → `Tool.execute()` - -Every tool call runs through this chain in order. Any stage can abort execution. - -``` -ExceptionFilters (wrap everything) - └── Guards (can_activate → bool) - └── Middleware (use(ctx, next)) - └── Interceptors (intercept(ctx, next)) - └── Pipes (transform(value, meta)) - └── Handler (your function) -``` - -### 2.1 Guards - -**TS source:** `guards/guard.interface.ts` - -```python -# Protocol definition -class Guard(Protocol): - async def can_activate(self, context: ExecutionContext) -> bool: ... - -# Usage -@use_guards(ApiKeyGuard, MyCustomGuard) -@tool(...) -async def my_tool(self, input, context): ... -``` - -Return `False` → raises `"Access denied by guard"` error automatically. - -**Built-in guards:** -- `ApiKeyGuard` — validates `x-api-key` from request metadata -- `JwtGuard` — validates Bearer JWT token, populates `context.auth` -- `OAuthGuard` — validates OAuth 2.1 access token with audience binding - ---- - -### 2.2 Middleware - -**TS source:** `middleware/middleware.interface.ts` - -```python -class MyMiddleware: - async def use(self, context: ExecutionContext, next: Callable) -> Any: - # before - result = await next() - # after - return result -``` - ---- - -### 2.3 Interceptors - -**TS source:** `interceptors/interceptor.interface.ts` - -```python -class MyInterceptor: - async def intercept(self, context: ExecutionContext, next: Callable) -> Any: - result = await next() - # transform result - return result -``` - ---- - -### 2.4 Pipes - -**TS source:** `pipes/pipe.interface.ts` - -```python -class MyPipe: - async def transform(self, value: Any, metadata: PipeMetadata) -> Any: - # validate or coerce - return value -``` - ---- - -### 2.5 Exception Filters - -**TS source:** `filters/exception-filter.interface.ts` - -```python -class MyFilter: - async def catch(self, error: Exception, context: ExecutionContext) -> Any: - # handle and return fallback result - return {"error": str(error)} -``` - ---- - -## 3. Module System - -**TS source:** `module.ts`, `app-decorator.ts` - -NestJS-style feature grouping. Each module declares its controllers and providers. - -**TS API:** -```typescript -@Module({ - name: 'calculator', - controllers: [CalculatorController], - providers: [CalculatorService], - imports: [ConfigModule], - exports: [CalculatorService], -}) -export class CalculatorModule {} -``` - -**Python API:** -```python -@module( - name="calculator", - controllers=[CalculatorController], - providers=[CalculatorService], - imports=[ConfigModule], - exports=[CalculatorService], -) -class CalculatorModule: ... -``` - ---- - -## 4. App Entry Point - -**TS source:** `app-decorator.ts` → `@McpApp`, `McpApplicationFactory` - -**TS API:** -```typescript -@McpApp({ module: AppModule, server: { name: 'my-server', version: '1.0.0' } }) -class App {} -const app = await McpApplicationFactory.create(App); -await app.start(); -``` - -**Python API:** -```python -@mcp_app(module=AppModule, server=ServerConfig(name="my-server", version="1.0.0")) -class App: ... - -async def main(): - app = await McpApplicationFactory.create(App) - await app.start() - -asyncio.run(main()) -``` - ---- - -## 5. Dependency Injection - -**TS source:** `di/container.ts`, `di/injectable.decorator.ts` - -Global singleton `DIContainer`. All providers are singleton-scoped by default. - -**TS API:** -```typescript -@Injectable() -export class CalculatorService { - constructor(private config: ConfigService) {} -} -``` - -**Python API (explicit deps — always required, no auto-inference from type hints):** -```python -@injectable(deps=[ConfigService]) -class CalculatorService: - def __init__(self, config: ConfigService): - self.config = config -``` - -**Container usage:** -```python -container = DIContainer.get_instance() -container.register(CalculatorService) -container.register_value("API_KEY", "my-key") -service = container.resolve(CalculatorService) -``` - -> **Note:** Python SDK always uses explicit `deps=[]`. Never attempt to auto-infer from type hints — this breaks under `from __future__ import annotations` (PEP 563). - ---- - -## 6. Built-in Auth Modules - -### 6.1 ApiKeyModule - -**TS source:** `apikey-module.ts` - -```python -ApiKeyModule.for_root( - keys_env_prefix="API_KEY", # reads API_KEY_1, API_KEY_2, etc. - header_name="x-api-key", - hashed=False, # set True if storing SHA-256 hashes -) -``` - -Methods: -- `ApiKeyModule.get_keys()` → list of configured keys -- `ApiKeyModule.validate(key)` → `bool` -- `ApiKeyModule.hash_key(key)` → SHA-256 hex string -- `ApiKeyModule.generate_key(prefix="sk")` → `"sk_"` secure key - ---- - -### 6.2 JWTModule - -**TS source:** `jwt-module.ts` - -```python -JWTModule.for_root( - secret_env_var="JWT_SECRET", - expires_in="24h", - audience="my-app", - issuer="https://auth.example.com", -) -``` - ---- - -### 6.3 OAuthModule - -**TS source:** `oauth-module.ts` - -Full OAuth 2.1 compliance: -- RFC 9728 — Protected Resource Metadata (`/.well-known/oauth-protected-resource`) -- RFC 8414 — Authorization Server Metadata (`/.well-known/oauth-authorization-server`) -- RFC 8707 — Token audience binding -- RFC 7636 — PKCE -- RFC 7662 — Token introspection - -```python -OAuthModule.for_root( - resource_uri=os.environ["RESOURCE_URI"], - authorization_servers=["https://auth.example.com"], - scopes_supported=["mcp:read", "mcp:write", "tools:execute"], - token_introspection_endpoint=os.environ.get("INTROSPECTION_ENDPOINT"), - token_introspection_client_id=os.environ.get("INTROSPECTION_CLIENT_ID"), - token_introspection_client_secret=os.environ.get("INTROSPECTION_CLIENT_SECRET"), -) -``` - -In stdio mode: starts a separate HTTP server on `OAUTH_DISCOVERY_PORT` (default 3005) to serve discovery endpoints, and notifies the client via stderr: -``` -[NITROSTACK_OAUTH]{...}[/NITROSTACK_OAUTH] -``` - ---- - -### 6.4 ConfigModule - -**TS source:** `config-module.ts` - -```python -ConfigModule.for_root( - env_file_path=".env", - ignore_env_file=False, - defaults={"LOG_LEVEL": "info"}, - validate=lambda cfg: "API_KEY" in cfg, -) - -# Usage via injection: -@injectable(deps=[ConfigService]) -class MyService: - def __init__(self, config: ConfigService): - self.api_key = config.get_or_throw("API_KEY") -``` - -> **Critical:** Parse `.env` manually — never use a library that writes to stdout. MCP stdio protocol uses stdout for JSON-RPC; any stray output breaks the connection. - ---- - -## 7. Additional Decorators - -**TS source:** `decorators/cache.decorator.ts`, `decorators/rate-limit.decorator.ts`, `decorators/health-check.decorator.ts` - -### 7.1 Cache - -```python -@cache(ttl=60) # cache output for 60 seconds -@tool(...) -async def my_tool(self, input, context): ... -``` - -### 7.2 Rate Limit - -```python -@rate_limit(max=10, window=60) # 10 calls per 60 seconds per context -@tool(...) -async def my_tool(self, input, context): ... -``` - -### 7.3 Health Check - -```python -@health_check("database") -def check_db(self) -> bool: - return self.db.ping() -``` - -Health checks are exposed as a built-in MCP resource at `health://status`. - ---- - -## 8. MCP Tasks (Async Long-Running Tools) - -**TS source:** `task.ts` - -For tools that take too long to complete synchronously. Server returns `taskId` immediately and runs the tool in the background. - -**Task support levels:** -- `"forbidden"` (default) — tool cannot be invoked as a task -- `"optional"` — can be called normally or as a task -- `"required"` — must always be invoked as a task - -**Progress reporting from inside the handler:** -```python -async def long_tool(self, input, context: ExecutionContext): - if context.task: - context.task.update_progress("Step 1 of 3...") - context.task.throw_if_cancelled() - # ... do work ... - context.task.update_progress("Step 2 of 3...") -``` - -**MCP Task protocol methods exposed:** -- `tasks/get` — get task status -- `tasks/list` — list all tasks -- `tasks/cancel` — cancel a running task -- `tasks/result` — wait for and get task result - ---- - -## 9. Events - -**TS source:** `events/event-emitter.ts`, `events/event.decorator.ts` - -Lightweight pub/sub event bus (singleton). - -```python -@on_event("user.created") -async def handle_user_created(self, payload: dict): ... - -# Emit from anywhere: -event_emitter = EventEmitter.get_instance() -await event_emitter.emit("user.created", {"id": 123}) -``` - ---- - -## 10. Transports - -**TS source:** `server.ts`, `transports/` - -Auto-detected from environment variables. **Never write to stdout** — it carries the JSON-RPC wire protocol in stdio mode. - -| Mode | When | How | -|---|---|---| -| `stdio` | `NODE_ENV` not set or `development` | Default, for Claude Desktop | -| `http` | `MCP_TRANSPORT_TYPE=http` | Streamable HTTP on `PORT` | -| `dual` | `NODE_ENV=production` | stdio + HTTP simultaneously | - -**Env vars:** -- `PORT` / `MCP_SERVER_PORT` — HTTP server port -- `MCP_TRANSPORT_TYPE` — override transport -- `OAUTH_DISCOVERY_PORT` — OAuth discovery HTTP port (default 3005) - -**HTTP endpoints:** -- `POST /mcp` — Streamable HTTP (primary) -- `GET /sse` + `POST /mcp/messages` — Legacy SSE (compatibility) - ---- - -## 11. App Modes - -**TS source:** `app-mode.ts` - -Controlled by `NITROSTACK_APP_MODE` env var: - -| Mode | Value | Use | -|---|---|---| -| Standard MCP | `mcp` (default) | Claude Desktop, standard clients | -| OpenAI Apps SDK | `openai` | OpenAI Assistants / ChatGPT plugins | -| MCP Apps Spec | `mcpapps` | MCP Apps spec compliance | - -In `openai` mode, tool `_meta` uses `openai/*` keys. In `mcpapps` mode, uses `_meta.ui` format. - ---- - -## 12. Execution Context - -**TS source:** `types.ts` → `ExecutionContext` - -Passed to every handler, guard, middleware, interceptor, pipe, and filter. - -```python -@dataclass -class ExecutionContext: - request_id: str # unique request ID for tracing - tool_name: str | None # name of tool being executed - logger: Logger # structured logger (file only, never stdout) - metadata: dict # raw request metadata - auth: AuthContext | None # populated if authenticated - task: TaskContext | None # populated for async task execution -``` - -**`AuthContext` fields:** -```python -@dataclass -class AuthContext: - subject: str | None # user/client identifier - scopes: list[str] # granted permissions - client_id: str | None # machine-to-machine - exp: int | None # expiration timestamp - iat: int | None # issued-at timestamp - iss: str | None # issuer URL - claims: dict # custom claims - token_payload: Any # full decoded token -``` - ---- - -## 13. Logger - -**TS source:** `logger.ts` - -Structured logger — **stdout disabled**, writes to file only. - -```python -class Logger(Protocol): - def debug(self, message: str, meta: dict | None = None) -> None: ... - def info(self, message: str, meta: dict | None = None) -> None: ... - def warn(self, message: str, meta: dict | None = None) -> None: ... - def error(self, message: str, meta: dict | None = None) -> None: ... -``` - -Recommended implementation: `loguru` or `structlog` with file-only sink. - ---- - -## 14. Error Types - -**TS source:** `errors.ts` - -```python -class ToolExecutionError(Exception): ... -class ValidationError(Exception): ... -class ResourceNotFoundError(Exception): ... -class PromptNotFoundError(Exception): ... -``` - -Errors in tool execution are returned as `isError: True` MCP responses, not protocol-level errors. - ---- - -## 15. Testing Module - -**TS source:** `testing/index.ts` - -In-process test harness — no real transport, no subprocess. - -```python -async def test_add_tool(): - module = await NitroTestingModule.create(AppModule) - result = await module.call_tool("add", {"a": 2, "b": 3}) - assert result == 5 - - resource = await module.read_resource("notes://all") - assert resource is not None - - prompt = await module.get_prompt("summarise", {"text": "hello"}) - assert len(prompt.messages) > 0 -``` - ---- - -## 16. CLI (`nitrostack-py`) - -Equivalent of `@nitrostack/cli` for Python projects. - -| Command | What it does | -|---|---| -| `nitrostack-py init my-server` | Scaffold new Python MCP server | -| `nitrostack-py dev` | Hot-reload dev server (uses `watchfiles`) | -| `nitrostack-py start` | Run the server | -| `nitrostack-py generate tool ` | Generate tool boilerplate | -| `nitrostack-py generate module ` | Generate module boilerplate | - -**Starter template structure:** -``` -my-server/ -├── main.py -├── app.module.py -├── modules/ -│ └── calculator/ -│ ├── calculator.module.py -│ ├── calculator.tools.py -│ └── calculator.service.py -├── .env -└── requirements.txt -``` - ---- - -## 17. Python Stack Decisions - -| TypeScript | Python equivalent | Reason | -|---|---|---| -| Zod | Pydantic v2 | Direct equivalent, built-in JSON Schema | -| `reflect-metadata` | Attributes on functions/classes | No runtime metadata system in Python | -| `emitDecoratorMetadata` | Explicit `deps=[]` always | PEP 563 makes type hint inference unreliable | -| Parameter decorators `@Inject()` | `deps=[]` in `@injectable` | Python has no parameter decorators | -| TypeScript interfaces | `typing.Protocol` | Structural subtyping | -| `winston` logger | `loguru` or `structlog` | File-only sink, no stdout | -| `@modelcontextprotocol/sdk` | `mcp` (official Python SDK) | Build on top, don't reimplement transport | -| Next.js widgets | Skip in v1 | Deeply Node.js-specific, not relevant to Python backend | - ---- - -## 18. What Python SDK Does NOT Port (v1 scope) - -| Feature | Reason | -|---|---| -| `@Widget()` and React component serving | Requires Next.js build pipeline — Node.js only | -| `@modelcontextprotocol/sdk` transport internals | The official `mcp` Python package handles this | -| `emitDecoratorMetadata` auto-DI | No Python equivalent; use explicit `deps=[]` | -| Parameter decorators (`@Inject()`) | Python language limitation | -| PostHog analytics in CLI | Nice-to-have, v2 | - ---- - -## 19. Deployment (No Managed Cloud) - -NitroStack has no proprietary hosting. The Python SDK follows the same model — bring your own infrastructure. - -**What the SDK provides for deployment:** -- Environment-based transport switching (`MCP_TRANSPORT_TYPE`) -- OAuth 2.1 for remote authenticated deployments -- Port config via env vars (`PORT`, `MCP_SERVER_PORT`, `OAUTH_DISCOVERY_PORT`) -- Health check endpoints for load balancers -- Structured file logging (no stdout pollution) - -**Tested deployment targets:** Railway, Fly.io, Render, any Docker/container host. diff --git a/__pycache__/nitrostudio.cpython-312.pyc b/__pycache__/nitrostudio.cpython-312.pyc deleted file mode 100644 index 7a2215a..0000000 Binary files a/__pycache__/nitrostudio.cpython-312.pyc and /dev/null differ diff --git a/dist/nitrostack-0.3.1-py3-none-any.whl b/dist/nitrostack-0.3.1-py3-none-any.whl deleted file mode 100644 index 68e2d0b..0000000 Binary files a/dist/nitrostack-0.3.1-py3-none-any.whl and /dev/null differ diff --git a/dist/nitrostack-0.3.1.tar.gz b/dist/nitrostack-0.3.1.tar.gz deleted file mode 100644 index d96eecf..0000000 Binary files a/dist/nitrostack-0.3.1.tar.gz and /dev/null differ diff --git a/examples/__pycache__/calculator_server.cpython-312.pyc b/examples/__pycache__/calculator_server.cpython-312.pyc index 5356d33..f0de664 100644 Binary files a/examples/__pycache__/calculator_server.cpython-312.pyc and b/examples/__pycache__/calculator_server.cpython-312.pyc differ diff --git a/examples/flight_booking_server.py b/examples/flight_booking_server.py index 76e6271..c8b7939 100644 --- a/examples/flight_booking_server.py +++ b/examples/flight_booking_server.py @@ -1,21 +1,28 @@ # Flight Booking MCP Server Example (with OAuth 2.1) # # This example demonstrates how to protect MCP tools using OAuth 2.1. -# The tools 'book_flight' and 'get_booking_details' are protected by the @use_guards(OAuthGuard) -# decorator, requiring a valid OAuth token from the configured introspection endpoint or JWKS provider. +# The tools are protected by the @use_guards(OAuthGuard, create_scope_guard([...])) +# decorators, requiring a valid OAuth token from the configured introspection endpoint or JWKS provider. # # To run this example, configure the OAuth settings in your .env file: # PORT=8000 +# RESOURCE_URI=https://mcplocal +# AUTH_SERVER_URL=https://dev-5dt0utuk315713tjm.us.auth0.com # OAUTH_INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect # # Or using JWKS: # # JWKS_URI=http://localhost:3000/oauth/jwks -# # TOKEN_AUDIENCE=flight-booking-service -# # TOKEN_ISSUER=http://localhost:3000/oauth +# # TOKEN_AUDIENCE=https://mcplocal +# # TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com import asyncio import os import sys -from typing import Dict, Any, List +import json +import uuid +import time +import urllib.request +import urllib.parse +from typing import Dict, Any, List, Optional from pydantic import BaseModel, Field # Ensure parent directory is in sys.path so nitrostack can be imported @@ -23,6 +30,8 @@ from nitrostack import ( tool, + prompt, + resource, injectable, module, mcp_app, @@ -30,146 +39,541 @@ ServerConfig, ExecutionContext, use_guards, + widget, OAuthGuard, + health_check, ) from nitrostack.auth.oauth import OAuthModule -# 1. Input Validation Schemas +# 1. Custom Scope Guard Factory +def create_scope_guard(required_scopes: list): + class ScopeGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + user_scopes = getattr(context.auth, "scopes", []) + missing_scopes = [s for s in required_scopes if s not in user_scopes] + if missing_scopes: + raise ValueError( + f"Insufficient scope. Required: {', '.join(required_scopes)}. " + f"Missing: {', '.join(missing_scopes)}" + ) + return True + return ScopeGuard + +# 2. Input Validation Schemas class SearchFlightsInput(BaseModel): - pass + origin: str = Field(description="Origin airport IATA code (e.g., 'JFK', 'LHR')") + destination: str = Field(description="Destination airport IATA code (e.g., 'LAX', 'CDG')") + departureDate: str = Field(description="Departure date in YYYY-MM-DD format") + returnDate: Optional[str] = Field(default=None, description="Return date in YYYY-MM-DD format for round trip") + adults: int = Field(default=1, description="Number of adult passengers (18+)") + cabinClass: str = Field(default="economy", description="Preferred cabin class (economy, premium_economy, business, first)") + +class FlightDetailsInput(BaseModel): + offerId: str = Field(description="The flight offer ID from search results") + +class AirportSearchInput(BaseModel): + query: str = Field(description="The search query for airports (e.g., 'London', 'New York')") + +class CreateOrderInput(BaseModel): + offerId: str = Field(description="The offer ID to book") + passengers: str = Field(description="JSON string containing array of passenger objects. Each passenger must have: title (mr/ms/mrs/miss/dr), givenName (first name), familyName (last name), gender (M/F), bornOn (YYYY-MM-DD), email, phoneNumber.") + +class OrderDetailsInput(BaseModel): + orderId: str = Field(description="The order ID") + +class SeatMapInput(BaseModel): + offerId: str = Field(description="The offer ID to get seats for") + +class CancelOrderInput(BaseModel): + orderId: str = Field(description="The order ID to cancel") -class BookFlightInput(BaseModel): - flight_id: str = Field(description="The flight identifier (e.g., FL101, FL202, FL303)") - passenger_name: str = Field(description="Passenger's full name") +class GetAirlinesInput(BaseModel): + pass -class BookingInfoInput(BaseModel): - booking_id: str = Field(description="The flight booking ID (e.g., BKG-5001)") -# 2. Flight Booking Business Service -@injectable(deps=[]) -class FlightBookingService: +# 3. Duffel Flight API Service +@injectable() +class DuffelService: def __init__(self): - # Mock flight schedules - self.flights = { - "FL101": {"from": "NYC", "to": "LON", "date": "2026-07-01", "price": 450.0}, - "FL202": {"from": "PAR", "to": "TOK", "date": "2026-07-02", "price": 850.0}, - "FL303": {"from": "LAX", "to": "NYC", "date": "2026-07-03", "price": 200.0} - } - self.bookings = {} - self.booking_counter = 5000 + self.api_key = os.environ.get("DUFFEL_API_KEY") + self.is_mock = not self.api_key or self.api_key.startswith("your-") or len(self.api_key) < 5 - def list_flights(self) -> Dict[str, Any]: - return self.flights + def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if self.is_mock: + raise ValueError("Duffel API key is not configured. Running in mock mode.") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Duffel-Version": "v1", + "Content-Type": "application/json" + } + url = f"https://api.duffel.com{path}" + req_data = json.dumps({"data": data}).encode("utf-8") if data else None + + req = urllib.request.Request(url, data=req_data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + res_payload = json.loads(response.read().decode("utf-8")) + return res_payload.get("data", {}) + except Exception as e: + sys.stderr.write(f"Duffel API Request failed: {e}\n") + sys.stderr.flush() + raise e - def book_flight(self, flight_id: str, passenger_name: str) -> Dict[str, Any]: - if flight_id not in self.flights: - raise ValueError(f"Flight {flight_id} not found.") + async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "orq_mock123456", + "offers": [ + { + "id": "off_mock123456", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": params["origin"], "name": "Origin Airport", "city_name": "Origin City"}, + "destination": {"iata_code": params["destination"], "name": "Dest Airport", "city_name": "Dest City"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_outbound", + "origin": {"iata_code": params["origin"]}, + "destination": {"iata_code": params["destination"]}, + "departing_at": f"{params['departureDate']}T08:00:00Z", + "arriving_at": f"{params['departureDate']}T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + ] + } - self.booking_counter += 1 - booking_id = f"BKG-{self.booking_counter}" + slices = [{"origin": params["origin"], "destination": params["destination"], "departure_date": params["departureDate"]}] + if params.get("returnDate"): + slices.append({"origin": params["destination"], "destination": params["origin"], "departure_date": params["returnDate"]}) + + duffel_params = { + "slices": slices, + "passengers": params.get("passengers", [{"type": "adult"}]), + "cabin_class": params.get("cabinClass", "economy"), + "return_offers": True + } + res = self._request("POST", "/offer_requests", duffel_params) + return { + "id": res.get("id"), + "offers": res.get("offers", []), + "passengers": res.get("passengers", []), + "slices": res.get("slices", []) + } + + async def get_offer(self, offer_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": offer_id, + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"}, + "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_mock", + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + return self._request("GET", f"/offers/{offer_id}") + + async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + { + "cabin_class": "economy", + "rows": [ + { + "row_number": 10, + "sections": [ + { + "elements": [ + { + "type": "seat", + "id": "seat_10a", + "designator": "10A", + "available_services": [{"total_amount": "25.00", "total_currency": "USD"}], + "disclosures": ["window"] + }, + { + "type": "seat", + "id": "seat_10b", + "designator": "10B", + "available_services": [{"total_amount": "0.00", "total_currency": "USD"}], + "disclosures": ["middle"] + } + ] + } + ] + } + ] + } + ] + res = self._request("GET", f"/seat_maps?offer_id={offer_id}") + return res if isinstance(res, list) else [] + + async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "ord_mock123456", + "status": "held", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "booking_reference": "ABCXYZ", + "passengers": [ + { + "id": f"pax_{idx}", + "given_name": p["given_name"], + "family_name": p["family_name"], + "type": "adult" + } for idx, p in enumerate(params["passengers"]) + ], + "slices": [ + { + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "duration": "PT6H30M", + "segments": [ + { + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z" + } + ] + } + ] + } - self.bookings[booking_id] = { - "booking_id": booking_id, - "flight_id": flight_id, - "passenger_name": passenger_name, - "price": self.flights[flight_id]["price"], - "status": "Confirmed" + order_payload = { + "selected_offers": params["selectedOffers"], + "passengers": params["passengers"], + "type": "hold" } - return self.bookings[booking_id] + return self._request("POST", "/orders", order_payload) - def get_booking(self, booking_id: str) -> Dict[str, Any]: - if booking_id not in self.bookings: - raise KeyError(f"Booking {booking_id} not found.") - return self.bookings[booking_id] + async def get_order(self, order_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": order_id, + "status": "held", + "total_amount": "450.00", + "total_currency": "USD", + "booking_reference": "ABCXYZ", + "created_at": "2026-06-25T12:00:00Z", + "expires_at": "2026-12-31T12:00:00Z", + "passengers": [ + { + "id": "pax_0", + "given_name": "John", + "family_name": "Doe", + "type": "adult", + "email": "john@example.com", + "phone_number": "+1234567890" + } + ], + "slices": [ + { + "id": "sli_mock", + "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"}, + "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_mock", + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + return self._request("GET", f"/orders/{order_id}") + async def cancel_order(self, order_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "ocr_mock123456", + "refund_amount": "450.00", + "refund_currency": "USD", + "confirmed_at": "2026-06-25T12:30:00Z" + } + cancel_payload = {"order_id": order_id} + return self._request("POST", "/order_cancellations", cancel_payload) -# 3. Controller exposing tools with OAuth Guards -@injectable(deps=[FlightBookingService]) -class FlightBookingController: - def __init__(self, service: FlightBookingService): + async def get_airlines(self) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + {"iata_code": "AA", "name": "American Airlines"}, + {"iata_code": "DL", "name": "Delta Air Lines"}, + {"iata_code": "UA", "name": "United Airlines"}, + {"iata_code": "BA", "name": "British Airways"} + ] + res = self._request("GET", "/airlines") + return res if isinstance(res, list) else [] + + +# 4. Controllers Exposing Tools +@injectable(deps=[DuffelService]) +class FlightTools: + def __init__(self, service: DuffelService): self.service = service @tool( name="search_flights", title="Search Flights", - description="Search for available flights and schedules (No Auth required)", + description="Search for flight offers based on origin, destination, dates, and preferences.", input_schema=SearchFlightsInput ) - async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> Dict[str, Any]: - context.logger.info("Searching available flights...") - return {"flights": self.service.list_flights()} + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("flight-search-results") + async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Searching flights from {input.origin} to {input.destination}") + return await self.service.search_flights(input.model_dump()) @tool( - name="book_flight", - title="Book Flight Ticket", - description="Book a flight ticket. Requires OAuth authentication.", - input_schema=BookFlightInput + name="get_flight_details", + title="Get Flight Details", + description="Get detailed information about a specific flight offer including baggage allowance, conditions.", + input_schema=FlightDetailsInput ) - @use_guards(OAuthGuard) - async def book_flight(self, input: BookFlightInput, context: ExecutionContext) -> Dict[str, Any]: - # OAuthGuard validates access token and populates context.auth - user_id = getattr(context.auth, "subject", "unknown-user") - context.logger.info(f"Booking flight {input.flight_id} for user {user_id}") - try: - result = self.service.book_flight(input.flight_id, input.passenger_name) - return {"status": "success", "booking": result} - except Exception as e: - return {"status": "failed", "error": str(e)} + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("flight-details") + async def get_flight_details(self, input: FlightDetailsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching flight details for offer {input.offerId}") + return await self.service.get_offer(input.offerId) @tool( - name="get_booking_details", - title="Get Booking Details", - description="Get booking receipt and details. Requires OAuth authentication.", - input_schema=BookingInfoInput + name="search_airports", + title="Search Airports", + description="Search for airports by query string.", + input_schema=AirportSearchInput ) - @use_guards(OAuthGuard) - async def get_booking_details(self, input: BookingInfoInput, context: ExecutionContext) -> Dict[str, Any]: - context.logger.info(f"Retrieving details for booking {input.booking_id}") + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("airport-search") + async def search_airports(self, input: AirportSearchInput, context: ExecutionContext) -> dict: + context.logger.info(f"Searching airports for query: {input.query}") + return {"airports": [{"iataCode": "JFK", "name": "John F. Kennedy Airport"}]} + + @tool( + name="get_airlines", + title="Get Airlines", + description="Get list of common airlines.", + input_schema=GetAirlinesInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def get_airlines(self, input: GetAirlinesInput, context: ExecutionContext) -> dict: + context.logger.info("Fetching common airlines") + res = await self.service.get_airlines() + return {"airlines": res} + + +@injectable(deps=[DuffelService]) +class BookingTools: + def __init__(self, service: DuffelService): + self.service = service + + @tool( + name="create_order", + title="Create Order", + description="Create a flight order with hold (no payment required). Collect passenger details first.", + input_schema=CreateOrderInput + ) + @use_guards(OAuthGuard, create_scope_guard(["write"])) + @widget("order-summary") + async def create_order(self, input: CreateOrderInput, context: ExecutionContext) -> dict: + context.logger.info(f"Creating flight order for offer {input.offerId}") try: - result = self.service.get_booking(input.booking_id) - return {"status": "success", "booking": result} - except Exception as e: - return {"status": "failed", "error": str(e)} + passengers_array = json.loads(input.passengers) + except Exception: + raise ValueError("Invalid passengers JSON format") + + passengers = [] + for p in passengers_array: + passengers.append({ + "title": p.get("title", "mr"), + "given_name": p.get("givenName"), + "family_name": p.get("familyName"), + "gender": p.get("gender", "M"), + "born_on": p.get("bornOn"), + "email": p.get("email"), + "phone_number": p.get("phoneNumber") + }) + + order_params = { + "selectedOffers": [input.offerId], + "passengers": passengers + } + return await self.service.create_order(order_params) + + @tool( + name="get_order_details", + title="Get Order Details", + description="Get detailed information about an order.", + input_schema=OrderDetailsInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("order-summary") + async def get_order_details(self, input: OrderDetailsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching details for order {input.orderId}") + return await self.service.get_order(input.orderId) + + @tool( + name="get_seat_map", + title="Get Seat Map", + description="Get available seats for a flight offer to allow seat selection.", + input_schema=SeatMapInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("seat-selection") + async def get_seat_map(self, input: SeatMapInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching seat map for offer {input.offerId}") + res = await self.service.get_seats_for_offer(input.offerId) + return {"offerId": input.offerId, "cabins": res} + + @tool( + name="cancel_order", + title="Cancel Order", + description="Cancel a flight order and request refund if applicable.", + input_schema=CancelOrderInput + ) + @use_guards(OAuthGuard, create_scope_guard(["write"])) + @widget("order-cancellation") + async def cancel_order(self, input: CancelOrderInput, context: ExecutionContext) -> dict: + context.logger.info(f"Cancelling order {input.orderId}") + return await self.service.cancel_order(input.orderId) + + +# 5. Flight Prompts +@injectable(deps=[DuffelService]) +class FlightPrompts: + def __init__(self, service: DuffelService): + self.service = service + + @prompt( + name="flight_search_assistant", + description="An AI assistant specialized in helping users search for flights and make booking decisions." + ) + async def flight_search_assistant(self, args: dict, context: ExecutionContext) -> str: + return "You are a professional flight booking assistant. Help the user search for flights using search_flights, search_airports, and assist in booking holds." + + @prompt( + name="flight_comparison", + description="Compare multiple flight offers and provide recommendations." + ) + async def flight_comparison(self, args: dict, context: ExecutionContext) -> str: + return "Compare the provided flight offer details, check layover times, durations, and pricing, and give the user a summary of best options." + + +# 6. Flight Resources +@injectable(deps=[DuffelService]) +class FlightResources: + def __init__(self, service: DuffelService): + self.service = service + + @resource( + uri="flight://popular-routes", + name="Popular Flight Routes", + description="Information about popular routes and pricing", + mime_type="application/json" + ) + async def popular_routes(self, context: ExecutionContext) -> dict: + return { + "routes": [ + {"route": "JFK -> LAX", "price": "$200-400"}, + {"route": "LHR -> JFK", "price": "$400-800"} + ] + } + + @resource( + uri="flight://booking-guide", + name="Flight Booking Guide", + description="Guide on searching and booking flights", + mime_type="text/markdown" + ) + async def booking_guide(self, context: ExecutionContext) -> str: + return "# Flight Booking Guide\n\n1. Search flights.\n2. Collect passenger info.\n3. Create order hold." + + +# 7. Health Check +class SystemHealthCheck: + def __init__(self): + self.start_time = time.time() + + @health_check("system") + def check_system(self) -> bool: + uptime = time.time() - self.start_time + return uptime >= 0 -# 4. Modules +# 8. Modules @module( - name="flight_booking", - controllers=[FlightBookingController], - providers=[FlightBookingService], - exports=[FlightBookingService] + name="flights", + controllers=[FlightTools, BookingTools, FlightPrompts, FlightResources], + providers=[DuffelService], + exports=[DuffelService] ) -class FlightBookingModule: +class FlightsModule: pass @module( name="app", imports=[ - FlightBookingModule, # Configure OAuth resource protection OAuthModule.for_root( - resource_uri="http://localhost:8000/mcp", - authorization_servers=["http://localhost:3000/oauth"], - scopes_supported=["flight:read", "flight:write"], - token_introspection_endpoint="http://localhost:3000/oauth/introspect" - ) - ] + resource_uri="https://mcplocal", + authorization_servers=["https://dev-5dt0utuk315713tjm.us.auth0.com"], + scopes_supported=["read", "write", "admin"], + token_introspection_endpoint=os.environ.get("INTROSPECTION_ENDPOINT") + ), + FlightsModule + ], + controllers=[], + providers=[SystemHealthCheck], + exports=[] ) class AppModule: pass -# 5. Application Entrypoint +# 9. Application Entrypoint @mcp_app( module=AppModule, - server=ServerConfig(name="flight-booking-server", version="1.0.0") + server=ServerConfig(name="airline-ticketing-server", version="1.0.0") ) class App: pass async def main(): - sys.stderr.write("Starting Flight Booking Server...\n") + sys.stderr.write("🔐 Starting Airline Ticketing MCP Server with OAuth 2.1...\n") sys.stderr.flush() app = await McpApplicationFactory.create(App) await app.start() diff --git a/my-test-server/__pycache__/app_module.cpython-312.pyc b/my-test-server/__pycache__/app_module.cpython-312.pyc deleted file mode 100644 index d73bb80..0000000 Binary files a/my-test-server/__pycache__/app_module.cpython-312.pyc and /dev/null differ diff --git a/my-test-server/modules/calculator/__pycache__/calculator_module.cpython-312.pyc b/my-test-server/modules/calculator/__pycache__/calculator_module.cpython-312.pyc deleted file mode 100644 index e50b56e..0000000 Binary files a/my-test-server/modules/calculator/__pycache__/calculator_module.cpython-312.pyc and /dev/null differ diff --git a/my-test-server/modules/calculator/__pycache__/calculator_service.cpython-312.pyc b/my-test-server/modules/calculator/__pycache__/calculator_service.cpython-312.pyc deleted file mode 100644 index 7083fe4..0000000 Binary files a/my-test-server/modules/calculator/__pycache__/calculator_service.cpython-312.pyc and /dev/null differ diff --git a/my-test-server/modules/calculator/__pycache__/calculator_tools.cpython-312.pyc b/my-test-server/modules/calculator/__pycache__/calculator_tools.cpython-312.pyc deleted file mode 100644 index f835e09..0000000 Binary files a/my-test-server/modules/calculator/__pycache__/calculator_tools.cpython-312.pyc and /dev/null differ diff --git a/nitrostack.egg-info/PKG-INFO b/nitrostack.egg-info/PKG-INFO deleted file mode 100644 index c1783c3..0000000 --- a/nitrostack.egg-info/PKG-INFO +++ /dev/null @@ -1,216 +0,0 @@ -Metadata-Version: 2.4 -Name: nitrostack -Version: 0.3.1 -Summary: NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -Requires-Dist: mcp>=1.0.0 -Requires-Dist: pydantic>=2.0.0 -Requires-Dist: starlette>=0.30.0 -Requires-Dist: uvicorn>=0.20.0 -Requires-Dist: watchfiles>=0.18.0 - -# NitroStack Python SDK - -A Python-idiomatic port of the **NitroStack** Model Context Protocol (MCP) framework, enabling NestJS-like modular architecture, dependency injection, execution pipelines, background task processing, built-in authentication modules, and a diagnostic testing harness. - ---- - -## Features - -- **Nested Modular Architecture**: Group components cleanly with `@module`. -- **Dependency Injection**: Explicit class constructor DI with `DIContainer` and `@injectable(deps=[...])`. -- **Pipeline Interceptors**: Build guards, middleware, interceptors, pipes, and exception filters for tool execution. -- **Asynchronous Background Tasks**: Spawn background workers automatically for long-running tools. -- **Built-in Authentication**: Modules for API Keys, JWT verification, and OAuth 2.1 (featuring Protected Resource Metadata discovery servers). -- **In-Process Testing Harness**: Run unit and integration tests against modules without managing subprocesses or real network transports. -- **CLI Tooling (`nitrostack-py`)**: Scaffold new apps (`init`), generate boilerplates (`generate`), auto-register servers with Claude (`register`), and run hot-reload development servers (`dev`). - ---- - -## Installation - -```bash -pip install nitrostack -``` - -To install local developer or test dependencies: -```bash -pip install -e . -``` - ---- - -## Scaffolding a New Project (Recommended) - -You can quickly scaffold a new project template using the interactive CLI tool: - -```bash -nitrostack-py init my-server -``` - -*(Or via Python: `python -m nitrostack.cli.main init my-server`)* - -This launches an interactive prompt where you can: -1. **Choose a template**: - - **Starter**: A simple calculator server. - - **Advanced**: A food delivery server with items and order status tracking. - - **OAuth**: A flight booking server demonstrating OAuth 2.1 authentication and guarded routes. -2. **Provide metadata**: Specify a custom description and author name. - -Once scaffolded, follow the next steps printed by the CLI to run your server, configure environment variables, and try it out. - ---- - -## NitroStudio Dashboard - -NitroStudio is an interactive visual developer dashboard for inspecting, graphing, and testing your MCP servers. - -To launch the dashboard, execute: -```bash -nitrostack-studio -``` -If your Python scripts directory is not configured in your system `PATH`, you can run it via Python: -```bash -python -m nitrostack.studio -``` -This launches the interface in your default web browser, allowing you to traverse directories, visualize your dependency graph, test tool execution forms, chat with a local mock LLM, and inspect RPC logs. - ---- - -## Quick Start - -### 1. Write your First Server - -Create a file named `app.py`: - -```python -import asyncio -from pydantic import BaseModel, Field -from nitrostack import ( - tool, - resource, - injectable, - module, - mcp_app, - McpApplicationFactory, - ServerConfig, - ExecutionContext, -) - -# 1. Input Validation Schema -class AddInput(BaseModel): - a: float = Field(description="First number") - b: float = Field(description="Second number") - -# 2. Injected Provider Service -@injectable(deps=[]) -class CalculatorService: - def add(self, a: float, b: float) -> float: - return a + b - -# 3. Controller -@injectable(deps=[CalculatorService]) -class CalculatorController: - def __init__(self, service: CalculatorService): - self.service = service - - @tool( - name="add", - description="Add two numbers together", - input_schema=AddInput - ) - async def add(self, input: AddInput, context: ExecutionContext) -> float: - context.logger.info(f"Adding {input.a} and {input.b}") - return self.service.add(input.a, input.b) - - @resource( - uri="calc://info", - name="Calculator Info", - description="Metadata about this calculator" - ) - async def get_info(self, context: ExecutionContext) -> str: - return "Simple Add Calculator v1.0.0" - -# 4. Modules -@module( - name="calculator", - controllers=[CalculatorController], - providers=[CalculatorService] -) -class CalculatorModule: - pass - -@module( - name="app", - imports=[CalculatorModule] -) -class AppModule: - pass - -# 5. Application Entrypoint -@mcp_app( - module=AppModule, - server=ServerConfig(name="math-server", version="1.0.0") -) -class App: - pass - -async def main(): - app = await McpApplicationFactory.create(App) - await app.start() - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### 2. Configure Environment Variables - -The SDK reads standard settings from the environment or `.env` files: - -| Environment Variable | Description | -|---|---| -| `PORT` / `MCP_SERVER_PORT` | The port to bind for HTTP/SSE transport (default: `8000`). | -| `MCP_TRANSPORT_TYPE` | Transport selection: `stdio`, `http`, or `dual` (combining stdio + HTTP/SSE). | -| `NODE_ENV` | If set to `production`, defaults to `dual` transport. Otherwise defaults to `stdio`. | -| `NITROSTACK_LOG_FILE` | Destination file for logs (default: `nitrostack.log`). | -| `NITROSTACK_LOG_LEVEL` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | -| `NITROSTACK_LOG_TO_STDOUT` | Set to `true` to allow logging to stdout under stdio transport (Caution: may corrupt protocol stream). | - ---- - -## Developing & Testing - -### Auto-Registering with Claude Desktop -To automatically configure your server script with Claude Desktop without any manual editing: -```bash -nitrostack-py register --name my-mcp-server --file app.py -``` -*(If your scripts folder is not in PATH, use: `python -m nitrostack.cli.main register --name my-mcp-server --file app.py`)* - -This detects all standard and Windows Store installation directories, sets up virtualenv executables, and writes the JSON configuration. Once registered, simply restart Claude Desktop. - -### Running Tests -To run the automated test suite, execute: -```bash -python tests/test_basic.py -python tests/test_tasks.py -python tests/test_initial_tool.py -``` - -### Testing Harness -Write in-process unit tests using the harness: -```python -import asyncio -from nitrostack.testing import NitroTestingModule -from app import AppModule - -async def test_add(): - harness = await NitroTestingModule.create(AppModule) - result = await harness.call_tool("add", {"input": {"a": 5, "b": 10}}) - assert result == 15.0 - print("Test passed!") - -if __name__ == "__main__": - asyncio.run(test_add()) -``` diff --git a/nitrostack.egg-info/SOURCES.txt b/nitrostack.egg-info/SOURCES.txt deleted file mode 100644 index 12a4830..0000000 --- a/nitrostack.egg-info/SOURCES.txt +++ /dev/null @@ -1,35 +0,0 @@ -README.md -pyproject.toml -nitrostack/__init__.py -nitrostack/studio.py -nitrostack.egg-info/PKG-INFO -nitrostack.egg-info/SOURCES.txt -nitrostack.egg-info/dependency_links.txt -nitrostack.egg-info/entry_points.txt -nitrostack.egg-info/requires.txt -nitrostack.egg-info/top_level.txt -nitrostack/auth/api_key.py -nitrostack/auth/config.py -nitrostack/auth/jwt.py -nitrostack/auth/oauth.py -nitrostack/cli/main.py -nitrostack/core/additional_decorators.py -nitrostack/core/app.py -nitrostack/core/context.py -nitrostack/core/decorators.py -nitrostack/core/di.py -nitrostack/core/errors.py -nitrostack/core/module.py -nitrostack/core/pipeline.py -nitrostack/core/task.py -nitrostack/events/event_emitter.py -nitrostack/static/app.js -nitrostack/static/index.html -nitrostack/static/styles.css -nitrostack/testing/__init__.py -nitrostack/transports/stdio.py -tests/test_basic.py -tests/test_initial_tool.py -tests/test_oauth.py -tests/test_production.py -tests/test_tasks.py \ No newline at end of file diff --git a/nitrostack.egg-info/dependency_links.txt b/nitrostack.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/nitrostack.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/nitrostack.egg-info/entry_points.txt b/nitrostack.egg-info/entry_points.txt deleted file mode 100644 index 7862392..0000000 --- a/nitrostack.egg-info/entry_points.txt +++ /dev/null @@ -1,3 +0,0 @@ -[console_scripts] -nitrostack-py = nitrostack.cli.main:main -nitrostack-studio = nitrostack.studio:start_server_cli diff --git a/nitrostack.egg-info/requires.txt b/nitrostack.egg-info/requires.txt deleted file mode 100644 index aecb838..0000000 --- a/nitrostack.egg-info/requires.txt +++ /dev/null @@ -1,5 +0,0 @@ -mcp>=1.0.0 -pydantic>=2.0.0 -starlette>=0.30.0 -uvicorn>=0.20.0 -watchfiles>=0.18.0 diff --git a/nitrostack/__init__.py b/nitrostack/__init__.py index ad8d6f0..c9a3055 100644 --- a/nitrostack/__init__.py +++ b/nitrostack/__init__.py @@ -5,6 +5,7 @@ resource, prompt, initial_tool, + widget, ToolAnnotations, ResourceAnnotations, PromptArgument, @@ -82,6 +83,7 @@ "resource", "prompt", "initial_tool", + "widget", "ToolAnnotations", "ResourceAnnotations", "PromptArgument", diff --git a/nitrostack/__pycache__/__init__.cpython-312.pyc b/nitrostack/__pycache__/__init__.cpython-312.pyc index cd61572..d4322cd 100644 Binary files a/nitrostack/__pycache__/__init__.cpython-312.pyc and b/nitrostack/__pycache__/__init__.cpython-312.pyc differ diff --git a/nitrostack/__pycache__/studio.cpython-312.pyc b/nitrostack/__pycache__/studio.cpython-312.pyc deleted file mode 100644 index db83b1a..0000000 Binary files a/nitrostack/__pycache__/studio.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/api_key.cpython-312.pyc b/nitrostack/auth/__pycache__/api_key.cpython-312.pyc index 17d249d..f3ae70a 100644 Binary files a/nitrostack/auth/__pycache__/api_key.cpython-312.pyc and b/nitrostack/auth/__pycache__/api_key.cpython-312.pyc differ diff --git a/nitrostack/auth/__pycache__/config.cpython-312.pyc b/nitrostack/auth/__pycache__/config.cpython-312.pyc index da820c7..f7f1b56 100644 Binary files a/nitrostack/auth/__pycache__/config.cpython-312.pyc and b/nitrostack/auth/__pycache__/config.cpython-312.pyc differ diff --git a/nitrostack/auth/__pycache__/jwt.cpython-312.pyc b/nitrostack/auth/__pycache__/jwt.cpython-312.pyc index 98cb2ae..ebacbe9 100644 Binary files a/nitrostack/auth/__pycache__/jwt.cpython-312.pyc and b/nitrostack/auth/__pycache__/jwt.cpython-312.pyc differ diff --git a/nitrostack/auth/__pycache__/oauth.cpython-312.pyc b/nitrostack/auth/__pycache__/oauth.cpython-312.pyc index 5f9b4d4..f451c9a 100644 Binary files a/nitrostack/auth/__pycache__/oauth.cpython-312.pyc and b/nitrostack/auth/__pycache__/oauth.cpython-312.pyc differ diff --git a/nitrostack/cli/__pycache__/main.cpython-312.pyc b/nitrostack/cli/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 19180f8..0000000 Binary files a/nitrostack/cli/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 6c64773..d673dfe 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -195,131 +195,571 @@ async def track_order(self, input: OrderStatusInput, context: ExecutionContext) return self.service.get_order_status(input.order_id) """ -FLIGHT_APP_MODULE_TEMPLATE = """from nitrostack import module -from nitrostack.auth.oauth import OAuthModule -from modules.flight_booking.flight_booking_module import FlightBookingModule +FLIGHT_APP_MODULE_TEMPLATE = """from nitrostack import module, ConfigModule, OAuthModule +from modules.flights.flights_module import FlightsModule +from health.system_health import SystemHealthCheck +import os @module( name="app", imports=[ - FlightBookingModule, + ConfigModule.for_root( + env_file_path=".env", + defaults={"RESOURCE_URI": "https://mcplocal", "PORT": "8000"} + ), # Configure OAuth resource protection OAuthModule.for_root( - resource_uri="http://localhost:8000/mcp", - authorization_servers=["http://localhost:3000/oauth"], - scopes_supported=["flight:read", "flight:write"], - token_introspection_endpoint="http://localhost:3000/oauth/introspect" - ) + resource_uri=os.environ.get("RESOURCE_URI", "https://mcplocal"), + authorization_servers=[os.environ.get("AUTH_SERVER_URL", "https://dev-5dt0utuk315713tjm.us.auth0.com")], + scopes_supported=["read", "write", "admin"], + token_introspection_endpoint=os.environ.get("INTROSPECTION_ENDPOINT"), + token_introspection_client_id=os.environ.get("INTROSPECTION_CLIENT_ID"), + token_introspection_client_secret=os.environ.get("INTROSPECTION_CLIENT_SECRET"), + audience=os.environ.get("TOKEN_AUDIENCE"), + issuer=os.environ.get("TOKEN_ISSUER") + ), + FlightsModule ], controllers=[], - providers=[], + providers=[SystemHealthCheck], exports=[] ) class AppModule: pass """ -FLIGHT_BOOKING_MODULE_TEMPLATE = """from nitrostack import module -from modules.flight_booking.flight_booking_tools import FlightBookingController -from modules.flight_booking.flight_booking_service import FlightBookingService - -@module( - name="flight_booking", - imports=[], - controllers=[FlightBookingController], - providers=[FlightBookingService], - exports=[FlightBookingService] -) -class FlightBookingModule: - pass +FLIGHT_OAUTH_GUARD_TEMPLATE = """from nitrostack import ExecutionContext + +def create_scope_guard(required_scopes: list): + class ScopeGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + user_scopes = getattr(context.auth, "scopes", []) + missing_scopes = [s for s in required_scopes if s not in user_scopes] + if missing_scopes: + raise ValueError( + f"Insufficient scope. Required: {', '.join(required_scopes)}. " + f"Missing: {', '.join(missing_scopes)}" + ) + return True + return ScopeGuard """ -FLIGHT_BOOKING_SERVICE_TEMPLATE = """from nitrostack import injectable +FLIGHT_SYSTEM_HEALTH_TEMPLATE = """import time +from nitrostack import health_check -@injectable(deps=[]) -class FlightBookingService: +class SystemHealthCheck: def __init__(self): - # Mock flight schedule - self.flights = { - "FL101": {"from": "NYC", "to": "LON", "date": "2026-07-01", "price": 450.0}, - "FL202": {"from": "PAR", "to": "TOK", "date": "2026-07-02", "price": 850.0}, - "FL303": {"from": "LAX", "to": "NYC", "date": "2026-07-03", "price": 200.0} - } - self.bookings = {} - self.booking_counter = 5000 + self.start_time = time.time() + + @health_check("system") + def check_system(self) -> bool: + uptime = time.time() - self.start_time + return uptime >= 0 +""" - def list_flights(self) -> dict: - return self.flights +FLIGHT_DUFFEL_SERVICE_TEMPLATE = """import os +import sys +import json +import urllib.request +import urllib.parse +from typing import List, Dict, Any, Optional +from nitrostack import injectable + +@injectable() +class DuffelService: + def __init__(self): + self.api_key = os.environ.get("DUFFEL_API_KEY") + self.is_mock = not self.api_key or self.api_key.startswith("your-") or len(self.api_key) < 5 - def book_flight(self, flight_id: str, passenger_name: str) -> dict: - if flight_id not in self.flights: - return {"status": "error", "message": f"Flight {flight_id} not found."} + def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if self.is_mock: + raise ValueError("Duffel API key is not configured. Running in mock mode.") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Duffel-Version": "v1", + "Content-Type": "application/json" + } + url = f"https://api.duffel.com{path}" + req_data = json.dumps({"data": data}).encode("utf-8") if data else None - self.booking_counter += 1 - booking_id = f"BKG-{self.booking_counter}" + req = urllib.request.Request(url, data=req_data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + res_payload = json.loads(response.read().decode("utf-8")) + return res_payload.get("data", {}) + except Exception as e: + sys.stderr.write(f"Duffel API Request failed: {e}\\n") + sys.stderr.flush() + raise e + + async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "orq_mock123456", + "offers": [ + { + "id": "off_mock123456", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": params["origin"], "name": "Origin Airport", "city_name": "Origin City"}, + "destination": {"iata_code": params["destination"], "name": "Dest Airport", "city_name": "Dest City"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_outbound", + "origin": {"iata_code": params["origin"]}, + "destination": {"iata_code": params["destination"]}, + "departing_at": f"{params['departureDate']}T08:00:00Z", + "arriving_at": f"{params['departureDate']}T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + ] + } - self.bookings[booking_id] = { - "booking_id": booking_id, - "flight_id": flight_id, - "passenger_name": passenger_name, - "price": self.flights[flight_id]["price"], - "status": "Confirmed" + slices = [ + { + "origin": params["origin"], + "destination": params["destination"], + "departure_date": params["departureDate"] + } + ] + if params.get("returnDate"): + slices.append({ + "origin": params["destination"], + "destination": params["origin"], + "departure_date": params["returnDate"] + }) + + duffel_params = { + "slices": slices, + "passengers": params.get("passengers", [{"type": "adult"}]), + "cabin_class": params.get("cabinClass", "economy"), + "return_offers": True + } + res = self._request("POST", "/offer_requests", duffel_params) + return { + "id": res.get("id"), + "offers": res.get("offers", []), + "passengers": res.get("passengers", []), + "slices": res.get("slices", []) } - return self.bookings[booking_id] - def get_booking(self, booking_id: str) -> dict: - return self.bookings.get(booking_id, {"status": "error", "message": f"Booking {booking_id} not found."}) + async def get_offer(self, offer_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": offer_id, + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"}, + "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_mock", + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + return self._request("GET", f"/offers/{offer_id}") + + async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + { + "cabin_class": "economy", + "rows": [ + { + "row_number": 10, + "sections": [ + { + "elements": [ + { + "type": "seat", + "id": "seat_10a", + "designator": "10A", + "available_services": [{"total_amount": "25.00", "total_currency": "USD"}], + "disclosures": ["window"] + }, + { + "type": "seat", + "id": "seat_10b", + "designator": "10B", + "available_services": [{"total_amount": "0.00", "total_currency": "USD"}], + "disclosures": ["middle"] + } + ] + } + ] + } + ] + } + ] + res = self._request("GET", f"/seat_maps?offer_id={offer_id}") + return res if isinstance(res, list) else [] + + async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "ord_mock123456", + "status": "held", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "booking_reference": "ABCXYZ", + "passengers": [ + { + "id": f"pax_{idx}", + "given_name": p["given_name"], + "family_name": p["family_name"], + "type": "adult" + } for idx, p in enumerate(params["passengers"]) + ], + "slices": [ + { + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "duration": "PT6H30M", + "segments": [ + { + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z" + } + ] + } + ] + } + + order_payload = { + "selected_offers": params["selectedOffers"], + "passengers": params["passengers"], + "type": "hold" + } + return self._request("POST", "/orders", order_payload) + + async def get_order(self, order_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": order_id, + "status": "held", + "total_amount": "450.00", + "total_currency": "USD", + "booking_reference": "ABCXYZ", + "created_at": "2026-06-25T12:00:00Z", + "expires_at": "2026-12-31T12:00:00Z", + "passengers": [ + { + "id": "pax_0", + "given_name": "John", + "family_name": "Doe", + "type": "adult", + "email": "john@example.com", + "phone_number": "+1234567890" + } + ], + "slices": [ + { + "id": "sli_mock", + "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"}, + "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_mock", + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + return self._request("GET", f"/orders/{order_id}") + + async def cancel_order(self, order_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "ocr_mock123456", + "refund_amount": "450.00", + "refund_currency": "USD", + "confirmed_at": "2026-06-25T12:30:00Z" + } + cancel_payload = {"order_id": order_id} + return self._request("POST", "/order_cancellations", cancel_payload) + + async def get_airlines(self) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + {"iata_code": "AA", "name": "American Airlines"}, + {"iata_code": "DL", "name": "Delta Air Lines"}, + {"iata_code": "UA", "name": "United Airlines"}, + {"iata_code": "BA", "name": "British Airways"} + ] + res = self._request("GET", "/airlines") + return res if isinstance(res, list) else [] """ -FLIGHT_BOOKING_TOOLS_TEMPLATE = """from nitrostack import injectable, tool, use_guards, OAuthGuard, ExecutionContext -from modules.flight_booking.flight_booking_service import FlightBookingService -from pydantic import BaseModel, Field +FLIGHT_MODULE_TEMPLATE = """from nitrostack import module +from modules.flights.flights_tools import FlightTools +from modules.flights.booking_tools import BookingTools +from modules.flights.flights_prompts import FlightPrompts +from modules.flights.flights_resources import FlightResources +from services.duffel_service import DuffelService -class SearchFlightsInput(BaseModel): +@module( + name="flights", + controllers=[FlightTools, BookingTools, FlightPrompts, FlightResources], + providers=[DuffelService], + exports=[DuffelService] +) +class FlightsModule: pass +""" -class BookFlightInput(BaseModel): - flight_id: str = Field(description="The flight identifier (e.g., FL101, FL202)") - passenger_name: str = Field(description="Passenger's full name") - -class BookingInfoInput(BaseModel): - booking_id: str = Field(description="The flight booking ID (e.g., BKG-5001)") +FLIGHT_TOOLS_TEMPLATE = """from nitrostack import injectable, tool, use_guards, OAuthGuard, ExecutionContext +from services.duffel_service import DuffelService +from guards.oauth_guard import create_scope_guard +from pydantic import BaseModel, Field +from typing import Optional -@injectable(deps=[FlightBookingService]) -class FlightBookingController: - def __init__(self, service: FlightBookingService): +class SearchFlightsInput(BaseModel): + origin: str = Field(description="Origin airport IATA code (e.g., 'JFK', 'LHR')") + destination: str = Field(description="Destination airport IATA code (e.g., 'LAX', 'CDG')") + departureDate: str = Field(description="Departure date in YYYY-MM-DD format") + returnDate: Optional[str] = Field(default=None, description="Return date in YYYY-MM-DD format for round trip") + adults: int = Field(default=1, description="Number of adult passengers (18+)") + cabinClass: str = Field(default="economy", description="Preferred cabin class (economy, premium_economy, business, first)") + +class FlightDetailsInput(BaseModel): + offerId: str = Field(description="The flight offer ID from search results") + +class AirportSearchInput(BaseModel): + query: str = Field(description="The search query for airports (e.g., 'London', 'New York')") + +@injectable(deps=[DuffelService]) +class FlightTools: + def __init__(self, service: DuffelService): self.service = service @tool( name="search_flights", - description="Search for available flights and schedules", + title="Search Flights", + description="Search for flight offers based on origin, destination, dates, and preferences.", input_schema=SearchFlightsInput ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict: - context.logger.info("Searching available flights...") - return {"flights": self.service.list_flights()} + context.logger.info(f"Searching flights from {input.origin} to {input.destination}") + res = await self.service.search_flights(input.model_dump()) + return res @tool( - name="book_flight", - description="Book a flight ticket. Requires OAuth authentication.", - input_schema=BookFlightInput + name="get_flight_details", + title="Get Flight Details", + description="Get detailed information about a specific flight offer including baggage allowance, conditions.", + input_schema=FlightDetailsInput ) - @use_guards(OAuthGuard) - async def book_flight(self, input: BookFlightInput, context: ExecutionContext) -> dict: - # OAuthGuard validates access token and populates context.auth - user_id = getattr(context.auth, "subject", "unknown-user") - context.logger.info(f"Booking flight {input.flight_id} for user {user_id}") - return self.service.book_flight(input.flight_id, input.passenger_name) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def get_flight_details(self, input: FlightDetailsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching flight details for offer {input.offerId}") + res = await self.service.get_offer(input.offerId) + return res @tool( - name="get_booking_details", - description="Get booking receipt and details. Requires OAuth authentication.", - input_schema=BookingInfoInput + name="search_airports", + title="Search Airports", + description="Search for airports by query string.", + input_schema=AirportSearchInput ) - @use_guards(OAuthGuard) - async def get_booking_details(self, input: BookingInfoInput, context: ExecutionContext) -> dict: - context.logger.info(f"Retrieving details for booking {input.booking_id}") - return self.service.get_booking(input.booking_id) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def search_airports(self, input: AirportSearchInput, context: ExecutionContext) -> dict: + context.logger.info(f"Searching airports for query: {input.query}") + return {"airports": [{"iataCode": "JFK", "name": "John F. Kennedy Airport"}]} + + @tool( + name="get_airlines", + title="Get Airlines", + description="Get list of common airlines." + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def get_airlines(self, context: ExecutionContext) -> dict: + context.logger.info("Fetching common airlines") + res = await self.service.get_airlines() + return {"airlines": res} +""" + +FLIGHT_BOOKING_TOOLS_TEMPLATE = """from nitrostack import injectable, tool, use_guards, OAuthGuard, ExecutionContext +from services.duffel_service import DuffelService +from guards.oauth_guard import create_scope_guard +from pydantic import BaseModel, Field +import json + +class CreateOrderInput(BaseModel): + offerId: str = Field(description="The offer ID to book") + passengers: str = Field(description="JSON string containing array of passenger objects. Each passenger must have: title (mr/ms/mrs/miss/dr), givenName (first name), familyName (last name), gender (M/F), bornOn (YYYY-MM-DD), email, phoneNumber.") + +class OrderDetailsInput(BaseModel): + orderId: str = Field(description="The order ID") + +class SeatMapInput(BaseModel): + offerId: str = Field(description="The offer ID to get seats for") + +class CancelOrderInput(BaseModel): + orderId: str = Field(description="The order ID to cancel") + +@injectable(deps=[DuffelService]) +class BookingTools: + def __init__(self, service: DuffelService): + self.service = service + + @tool( + name="create_order", + title="Create Order", + description="Create a flight order with hold (no payment required). Collect passenger details first.", + input_schema=CreateOrderInput + ) + @use_guards(OAuthGuard, create_scope_guard(["write"])) + async def create_order(self, input: CreateOrderInput, context: ExecutionContext) -> dict: + context.logger.info(f"Creating flight order for offer {input.offerId}") + try: + passengers_array = json.loads(input.passengers) + except Exception: + raise ValueError("Invalid passengers JSON format") + + passengers = [] + for p in passengers_array: + passengers.append({ + "title": p.get("title", "mr"), + "given_name": p.get("givenName"), + "family_name": p.get("familyName"), + "gender": p.get("gender", "M"), + "born_on": p.get("bornOn"), + "email": p.get("email"), + "phone_number": p.get("phoneNumber") + }) + + order_params = { + "selectedOffers": [input.offerId], + "passengers": passengers + } + res = await self.service.create_order(order_params) + return res + + @tool( + name="get_order_details", + title="Get Order Details", + description="Get detailed information about an order.", + input_schema=OrderDetailsInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def get_order_details(self, input: OrderDetailsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching details for order {input.orderId}") + res = await self.service.get_order(input.orderId) + return res + + @tool( + name="get_seat_map", + title="Get Seat Map", + description="Get available seats for a flight offer to allow seat selection.", + input_schema=SeatMapInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def get_seat_map(self, input: SeatMapInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching seat map for offer {input.offerId}") + res = await self.service.get_seats_for_offer(input.offerId) + return {"offerId": input.offerId, "cabins": res} + + @tool( + name="cancel_order", + title="Cancel Order", + description="Cancel a flight order and request refund if applicable.", + input_schema=CancelOrderInput + ) + @use_guards(OAuthGuard, create_scope_guard(["write"])) + async def cancel_order(self, input: CancelOrderInput, context: ExecutionContext) -> dict: + context.logger.info(f"Cancelling order {input.orderId}") + res = await self.service.cancel_order(input.orderId) + return res +""" + +FLIGHT_PROMPTS_TEMPLATE = """from nitrostack import injectable, prompt, ExecutionContext +from services.duffel_service import DuffelService +from typing import List + +@injectable(deps=[DuffelService]) +class FlightPrompts: + def __init__(self, service: DuffelService): + self.service = service + + @prompt( + name="flight_search_assistant", + description="An AI assistant specialized in helping users search for flights and make booking decisions." + ) + async def flight_search_assistant(self, args: dict, context: ExecutionContext) -> str: + return "You are a professional flight booking assistant. Help the user search for flights using search_flights, search_airports, and assist in booking holds." + + @prompt( + name="flight_comparison", + description="Compare multiple flight offers and provide recommendations." + ) + async def flight_comparison(self, args: dict, context: ExecutionContext) -> str: + return "Compare the provided flight offer details, check layover times, durations, and pricing, and give the user a summary of best options." +""" + +FLIGHT_RESOURCES_TEMPLATE = """from nitrostack import injectable, resource, ExecutionContext +from services.duffel_service import DuffelService + +@injectable(deps=[DuffelService]) +class FlightResources: + def __init__(self, service: DuffelService): + self.service = service + + @resource( + uri="flight://popular-routes", + name="Popular Flight Routes", + description="Information about popular routes and pricing", + mime_type="application/json" + ) + async def popular_routes(self, context: ExecutionContext) -> dict: + return { + "routes": [ + {"route": "JFK -> LAX", "price": "$200-400"}, + {"route": "LHR -> JFK", "price": "$400-800"} + ] + } + + @resource( + uri="flight://booking-guide", + name="Flight Booking Guide", + description="Guide on searching and booking flights", + mime_type="text/markdown" + ) + async def booking_guide(self, context: ExecutionContext) -> str: + return "# Flight Booking Guide\\n\\n1. Search flights.\\n2. Collect passenger info.\\n3. Create order hold." """ OAUTH_SETUP_TEMPLATE = """# OAuth 2.1 Server Setup Guide @@ -336,16 +776,16 @@ async def get_booking_details(self, input: BookingInfoInput, context: ExecutionC # Or use JWKS (JSON Web Key Sets) to cryptographically verify signatures locally # JWKS_URI=http://localhost:3000/oauth/jwks -# TOKEN_AUDIENCE=flight-booking-service -# TOKEN_ISSUER=http://localhost:3000/oauth +# TOKEN_AUDIENCE=https://mcplocal +# TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com ``` ## 2. Protected Routes -The tools in this server use the `@use_guards(OAuthGuard)` decorator to automatically protect endpoints: -* **`search_flights`**: Public (no guard). -* **`book_flight`**: Protected (requires valid access token with `flight:write` scope). -* **`get_booking_details`**: Protected (requires valid access token with `flight:read` scope). +The tools in this server use the `@use_guards(OAuthGuard, create_scope_guard([...]))` decorators to automatically protect endpoints: +* **Public**: No guards (or custom public filters). +* **Read-Protected**: Requires valid access token with `read` scope. +* **Write-Protected**: Requires valid access token with `write` scope. When calling protected tools, the client must pass a valid Bearer token in the `Authorization` header. """ @@ -419,8 +859,8 @@ def init_project(name: str, template: str = None): # 2. Select template if not template: print("\033[32m? \033[1;37mChoose a template:\033[0m") - print(" \033[34m1. Starter\033[0m Simple calculator for learning basics") - print(" \033[34m2. Advanced\033[0m Food delivery with items & status tracking") + print(" \033[34m1. Starter\033[0m Simple calculator/converter for learning basics") + print(" \033[34m2. Advanced\033[0m Pizza shop finder with maps & widgets") print(" \033[34m3. OAuth\033[0m Flight booking with OAuth 2.1 auth") while True: @@ -428,23 +868,22 @@ def init_project(name: str, template: str = None): sys.stdout.flush() choice = sys.stdin.readline().strip() if not choice or choice == "1": - template = "calculator" + template = "starter" break elif choice == "2": - template = "food-delivery" + template = "pizzaz" break elif choice == "3": template = "flight-booking" break - else: - print("Invalid choice. Please select 1, 2, or 3.") else: # Normalize command line template input - if template == "starter": - template = "calculator" - elif template == "advanced": - template = "food-delivery" - elif template == "oauth": + template = template.lower() + if template in ("starter", "calculator"): + template = "starter" + elif template in ("advanced", "pizzaz", "food-delivery"): + template = "pizzaz" + elif template in ("oauth", "flight-booking"): template = "flight-booking" # 3. Description and Author @@ -456,52 +895,67 @@ def init_project(name: str, template: str = None): sys.stdout.flush() author = sys.stdin.readline().strip() or "developer" + # 4. Copy template directory + import nitrostack + import shutil + package_dir = os.path.dirname(nitrostack.__file__) + template_src_dir = os.path.join(package_dir, "templates", template) + + if not os.path.exists(template_src_dir): + print(f"Error: Template '{template}' not found at '{template_src_dir}'.") + sys.exit(1) + + shutil.copytree(template_src_dir, name) print("\n\033[32m✓\033[0m Project created") print("\033[32m✓\033[0m Dependencies installed") - print("\033[32m✓\033[0m Widget dependencies installed\n") - # Write template files - if template == "calculator": - os.makedirs(os.path.join(name, "modules", "calculator"), exist_ok=True) - with open(os.path.join(name, "app_module.py"), "w", encoding="utf-8") as f: - f.write(APP_MODULE_TEMPLATE) - with open(os.path.join(name, "modules", "calculator", "calculator_module.py"), "w", encoding="utf-8") as f: - f.write(CALC_MODULE_TEMPLATE) - with open(os.path.join(name, "modules", "calculator", "calculator_service.py"), "w", encoding="utf-8") as f: - f.write(CALC_SERVICE_TEMPLATE) - with open(os.path.join(name, "modules", "calculator", "calculator_tools.py"), "w", encoding="utf-8") as f: - f.write(CALC_TOOLS_TEMPLATE) - elif template == "food-delivery": - os.makedirs(os.path.join(name, "modules", "food_delivery"), exist_ok=True) - with open(os.path.join(name, "app_module.py"), "w", encoding="utf-8") as f: - f.write(FOOD_APP_MODULE_TEMPLATE) - with open(os.path.join(name, "modules", "food_delivery", "food_delivery_module.py"), "w", encoding="utf-8") as f: - f.write(FOOD_DELIVERY_MODULE_TEMPLATE) - with open(os.path.join(name, "modules", "food_delivery", "food_delivery_service.py"), "w", encoding="utf-8") as f: - f.write(FOOD_DELIVERY_SERVICE_TEMPLATE) - with open(os.path.join(name, "modules", "food_delivery", "food_delivery_tools.py"), "w", encoding="utf-8") as f: - f.write(FOOD_DELIVERY_TEMPLATE) if 'FOOD_DELIVERY_TEMPLATE' in globals() else f.write(FOOD_DELIVERY_TOOLS_TEMPLATE) - elif template == "flight-booking": - os.makedirs(os.path.join(name, "modules", "flight_booking"), exist_ok=True) - with open(os.path.join(name, "app_module.py"), "w", encoding="utf-8") as f: - f.write(FLIGHT_APP_MODULE_TEMPLATE) - with open(os.path.join(name, "modules", "flight_booking", "flight_booking_module.py"), "w", encoding="utf-8") as f: - f.write(FLIGHT_BOOKING_MODULE_TEMPLATE) - with open(os.path.join(name, "modules", "flight_booking", "flight_booking_service.py"), "w", encoding="utf-8") as f: - f.write(FLIGHT_BOOKING_SERVICE_TEMPLATE) - with open(os.path.join(name, "modules", "flight_booking", "flight_booking_tools.py"), "w", encoding="utf-8") as f: - f.write(FLIGHT_BOOKING_TOOLS_TEMPLATE) - with open(os.path.join(name, "OAUTH_SETUP.md"), "w", encoding="utf-8") as f: - f.write(OAUTH_SETUP_TEMPLATE) + # 5. Update .env file + env_path = os.path.join(name, ".env") + if os.path.exists(env_path): + with open(env_path, "r", encoding="utf-8") as f: + lines = f.readlines() + new_lines = [] + for line in lines: + if line.startswith("SERVER_DESC="): + new_lines.append(f'SERVER_DESC="{description}"\n') + elif line.startswith("SERVER_AUTHOR="): + new_lines.append(f'SERVER_AUTHOR="{author}"\n') + else: + new_lines.append(line) + has_desc = any(line.startswith("SERVER_DESC=") for line in new_lines) + has_author = any(line.startswith("SERVER_AUTHOR=") for line in new_lines) + if not has_desc: + new_lines.append(f'SERVER_DESC="{description}"\n') + if not has_author: + new_lines.append(f'SERVER_AUTHOR="{author}"\n') + with open(env_path, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + # 6. Update widgets package.json + widgets_package_path = os.path.join(name, "src", "widgets", "package.json") + if os.path.exists(widgets_package_path): + import json + try: + with open(widgets_package_path, "r", encoding="utf-8") as f: + pkg = json.load(f) + pkg["name"] = f"{name}-widgets" + with open(widgets_package_path, "w", encoding="utf-8") as f: + json.dump(pkg, f, indent=2) + except Exception: + pass + + # 7. Run npm install inside widgets directory + widgets_dir = os.path.join(name, "src", "widgets") + if os.path.exists(widgets_dir): + print("Installing widget dependencies...") + try: + subprocess.run(["npm", "--version"], shell=True, capture_output=True, check=True) + subprocess.run(["npm", "install"], cwd=widgets_dir, shell=True, check=True) + print("\033[32m✓\033[0m Widget dependencies installed\n") + except Exception as e: + print(f"Warning: Failed to install widget dependencies: {e}") + print("Please run 'npm install' inside 'src/widgets' manually.\n") - with open(os.path.join(name, "main.py"), "w", encoding="utf-8") as f: - f.write(MAIN_TEMPLATE) - with open(os.path.join(name, ".env"), "w", encoding="utf-8") as f: - env_content = f"PORT=8000\nNODE_ENV=development\nSERVER_DESC=\"{description}\"\nSERVER_AUTHOR=\"{author}\"\n" - f.write(env_content) - with open(os.path.join(name, "requirements.txt"), "w", encoding="utf-8") as f: - f.write(REQUIREMENTS_TEMPLATE) - # Success Card abs_path = os.path.abspath(name) success_box = f"""\033[36m╔══════════════════════════════════════════════════════════╗ @@ -533,7 +987,22 @@ def run_dev(): print(f"Starting hot-reload development server for {target}...") process = None + widgets_process = None + # Check if Next.js widgets are present + widgets_dir = os.path.join(os.getcwd(), "src", "widgets") + if os.path.exists(widgets_dir) and os.path.exists(os.path.join(widgets_dir, "package.json")): + print("Starting widget development server on port 3001...") + try: + # Spawn npm run dev -- --port 3001 + widgets_process = subprocess.Popen( + ["npm", "run", "dev", "--", "--port", "3001"], + cwd=widgets_dir, + shell=True + ) + except Exception as e: + print(f"Warning: Could not start widget development server: {e}") + def start_process(): nonlocal process if process: @@ -549,82 +1018,88 @@ def start_process(): env["PYTHONPATH"] = os.path.abspath(".") process = subprocess.Popen([sys.executable, target], env=env) - start_process() - - watched_extensions = {".py", ".env"} - - def get_mtimes(): - mtimes = {} - for root, dirs, files in os.walk("."): - if any(part.startswith(".") or part in ("venv", "env", "__pycache__") for part in root.split(os.sep)): - continue - for file in files: - ext = os.path.splitext(file)[1] - if ext in watched_extensions: - path = os.path.join(root, file) - try: - mtimes[path] = os.path.getmtime(path) - except Exception: - pass - return mtimes - - # Try using watchfiles for high-performance, low-CPU file monitoring + def cleanup(): + nonlocal process, widgets_process + if process: + try: + process.terminate() + except Exception: + pass + if widgets_process: + try: + widgets_process.terminate() + except Exception: + pass + try: - from watchfiles import watch - print("Using watchfiles for high-performance file monitoring.") + start_process() - # Check if process is running and restart if needed - while True: - # We watch files blockingly. watch() yields when changes happen. - for changes in watch("."): - should_restart = False - for change_type, path in changes: - ext = os.path.splitext(path)[1] + watched_extensions = {".py", ".env"} + + def get_mtimes(): + mtimes = {} + for root, dirs, files in os.walk("."): + if any(part.startswith(".") or part in ("venv", "env", "__pycache__") for part in root.split(os.sep)): + continue + for file in files: + ext = os.path.splitext(file)[1] if ext in watched_extensions: - # Ignore standard hidden or package environment folders - parts = os.path.normpath(path).split(os.sep) - if not any(p in parts for p in ("venv", "env", "__pycache__", ".git", ".pytest_cache", "nitrostack.egg-info")): - should_restart = True - break - if should_restart: - print("File changes detected! Restarting server...") - start_process() - - # Fallback sleep if loop exits - time.sleep(0.5) - if process and process.poll() is not None: - print("Server process exited. Waiting for file changes to restart...") - - except ImportError: - print("watchfiles library not found. Falling back to standard polling...") - last_mtimes = get_mtimes() + path = os.path.join(root, file) + try: + mtimes[path] = os.path.getmtime(path) + except Exception: + pass + return mtimes + + # Try using watchfiles for high-performance, low-CPU file monitoring try: + from watchfiles import watch + print("Using watchfiles for high-performance file monitoring.") + while True: - time.sleep(1) + for changes in watch("."): + should_restart = False + for change_type, path in changes: + ext = os.path.splitext(path)[1] + if ext in watched_extensions: + parts = os.path.normpath(path).split(os.sep) + if not any(p in parts for p in ("venv", "env", "__pycache__", ".git", ".pytest_cache", "nitrostack.egg-info")): + should_restart = True + break + if should_restart: + print("File changes detected! Restarting server...") + start_process() + + time.sleep(0.5) if process and process.poll() is not None: print("Server process exited. Waiting for file changes to restart...") - current_mtimes = get_mtimes() - changed = False - if set(current_mtimes.keys()) != set(last_mtimes.keys()): - changed = True - else: - for path, mtime in current_mtimes.items(): - if last_mtimes.get(path) != mtime: - changed = True - break - if changed: - print("File changes detected! Restarting server...") - start_process() - last_mtimes = current_mtimes - except KeyboardInterrupt: - pass - except KeyboardInterrupt: - print("\nStopping development server...") - if process: + + except ImportError: + print("watchfiles library not found. Falling back to standard polling...") + last_mtimes = get_mtimes() try: - process.terminate() - except Exception: + while True: + time.sleep(1) + if process and process.poll() is not None: + print("Server process exited. Waiting for file changes to restart...") + current_mtimes = get_mtimes() + changed = False + if set(current_mtimes.keys()) != set(last_mtimes.keys()): + changed = True + else: + for path, mtime in current_mtimes.items(): + if last_mtimes.get(path) != mtime: + changed = True + break + if changed: + print("File changes detected! Restarting server...") + start_process() + last_mtimes = current_mtimes + except KeyboardInterrupt: pass + except KeyboardInterrupt: + print("\nStopping development server...") + cleanup() def run_start(): target = "main.py" @@ -764,7 +1239,7 @@ def main(): # init command init_parser = subparsers.add_parser("init", help="Initialize a new NitroStack MCP server project") init_parser.add_argument("name", help="Name of the project directory to create") - init_parser.add_argument("--template", choices=["calculator", "food-delivery", "flight-booking"], default=None, help="Template to use (default: interactive prompt)") + init_parser.add_argument("--template", choices=["calculator", "food-delivery", "flight-booking", "starter", "pizzaz", "oauth"], default=None, help="Template to use (default: interactive prompt)") # dev command subparsers.add_parser("dev", help="Start the hot-reloading development server") diff --git a/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc b/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc index fa58152..7c978a4 100644 Binary files a/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc and b/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/app.cpython-312.pyc b/nitrostack/core/__pycache__/app.cpython-312.pyc index 7338bea..2f7c654 100644 Binary files a/nitrostack/core/__pycache__/app.cpython-312.pyc and b/nitrostack/core/__pycache__/app.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/context.cpython-312.pyc b/nitrostack/core/__pycache__/context.cpython-312.pyc index dde59c8..6731ee2 100644 Binary files a/nitrostack/core/__pycache__/context.cpython-312.pyc and b/nitrostack/core/__pycache__/context.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/decorators.cpython-312.pyc b/nitrostack/core/__pycache__/decorators.cpython-312.pyc index 19ceaf9..a92614e 100644 Binary files a/nitrostack/core/__pycache__/decorators.cpython-312.pyc and b/nitrostack/core/__pycache__/decorators.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/di.cpython-312.pyc b/nitrostack/core/__pycache__/di.cpython-312.pyc index 160f32d..0d33e9b 100644 Binary files a/nitrostack/core/__pycache__/di.cpython-312.pyc and b/nitrostack/core/__pycache__/di.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/errors.cpython-312.pyc b/nitrostack/core/__pycache__/errors.cpython-312.pyc index ed4606b..734f478 100644 Binary files a/nitrostack/core/__pycache__/errors.cpython-312.pyc and b/nitrostack/core/__pycache__/errors.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/module.cpython-312.pyc b/nitrostack/core/__pycache__/module.cpython-312.pyc index 7e9a1e4..63d733e 100644 Binary files a/nitrostack/core/__pycache__/module.cpython-312.pyc and b/nitrostack/core/__pycache__/module.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/pipeline.cpython-312.pyc b/nitrostack/core/__pycache__/pipeline.cpython-312.pyc index 1c697f7..879e925 100644 Binary files a/nitrostack/core/__pycache__/pipeline.cpython-312.pyc and b/nitrostack/core/__pycache__/pipeline.cpython-312.pyc differ diff --git a/nitrostack/core/__pycache__/task.cpython-312.pyc b/nitrostack/core/__pycache__/task.cpython-312.pyc index 06de824..d3cec68 100644 Binary files a/nitrostack/core/__pycache__/task.cpython-312.pyc and b/nitrostack/core/__pycache__/task.cpython-312.pyc differ diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 8d51ba0..b694ab7 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -485,6 +485,12 @@ async def background_execution(): "task_support": tool_config.task_support, **(tool_config.metadata or {}) } + widget_route = getattr(method, "_mcp_widget", None) + if widget_route: + meta["ui/template"] = widget_route + meta["ui"] = {"resourceUri": widget_route} + meta["openai/outputTemplate"] = widget_route + if tool_config.invocation: meta["openai/toolInvocation/invoking"] = tool_config.invocation.invoking meta["openai/toolInvocation/invoked"] = tool_config.invocation.invoked diff --git a/nitrostack/core/decorators.py b/nitrostack/core/decorators.py index 033bef7..debdc84 100644 --- a/nitrostack/core/decorators.py +++ b/nitrostack/core/decorators.py @@ -106,10 +106,30 @@ def decorator(func: Callable): metadata=metadata, is_initial=getattr(func, "_mcp_is_initial", False) ) + # Check if function already had a widget decorator applied first + widget_route = getattr(func, "_mcp_widget", None) + if widget_route: + config.metadata["ui/template"] = widget_route + config.metadata["ui"] = {"resourceUri": widget_route} + config.metadata["openai/outputTemplate"] = widget_route + func._mcp_tool_config = config return func return decorator +def widget(route_path: str): + """ + Decorator to associate a UI widget route with a tool. + """ + def decorator(func: Callable): + func._mcp_widget = route_path + if hasattr(func, "_mcp_tool_config"): + func._mcp_tool_config.metadata["ui/template"] = route_path + func._mcp_tool_config.metadata["ui"] = {"resourceUri": route_path} + func._mcp_tool_config.metadata["openai/outputTemplate"] = route_path + return func + return decorator + def initial_tool(func: Callable): """ Stacked decorator with @tool to mark it as auto-called on client connection. diff --git a/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc b/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc index a89f243..7d8820e 100644 Binary files a/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc and b/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc differ diff --git a/nitrostack/templates/flight-booking/.env b/nitrostack/templates/flight-booking/.env new file mode 100644 index 0000000..b530c25 --- /dev/null +++ b/nitrostack/templates/flight-booking/.env @@ -0,0 +1,7 @@ +RESOURCE_URI=https://mcplocal +AUTH_SERVER_URL=https://dev-5dt0utuk315713tjm.us.auth0.com +TOKEN_AUDIENCE=https://mcplocal +TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com +DUFFEL_API_KEY=your-duffel-api-key +PORT=8000 +NODE_ENV=development diff --git a/nitrostack/templates/flight-booking/.env.example b/nitrostack/templates/flight-booking/.env.example new file mode 100644 index 0000000..b530c25 --- /dev/null +++ b/nitrostack/templates/flight-booking/.env.example @@ -0,0 +1,7 @@ +RESOURCE_URI=https://mcplocal +AUTH_SERVER_URL=https://dev-5dt0utuk315713tjm.us.auth0.com +TOKEN_AUDIENCE=https://mcplocal +TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com +DUFFEL_API_KEY=your-duffel-api-key +PORT=8000 +NODE_ENV=development diff --git a/nitrostack/templates/flight-booking/OAUTH_SETUP.md b/nitrostack/templates/flight-booking/OAUTH_SETUP.md new file mode 100644 index 0000000..e1f399c --- /dev/null +++ b/nitrostack/templates/flight-booking/OAUTH_SETUP.md @@ -0,0 +1,26 @@ +# OAuth 2.1 Server Setup Guide + +To run your flight booking MCP server with OAuth 2.1 protection, you need to configure an OAuth authorization server (like Keycloak, Auth0, Hydra, or a local mock OAuth server). + +## 1. Local Configuration + +Add the following environment variables to your `.env` file to configure resource protection: + +```env +# Introspection endpoint to validate access tokens +OAUTH_INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect + +# Or use JWKS (JSON Web Key Sets) to cryptographically verify signatures locally +# JWKS_URI=http://localhost:3000/oauth/jwks +# TOKEN_AUDIENCE=https://mcplocal +# TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com +``` + +## 2. Protected Routes + +The tools in this server use the `@use_guards(OAuthGuard, create_scope_guard([...]))` decorators to automatically protect endpoints: +* **Public**: No guards (or custom public filters). +* **Read-Protected**: Requires valid access token with `read` scope. +* **Write-Protected**: Requires valid access token with `write` scope. + +When calling protected tools, the client must pass a valid Bearer token in the `Authorization` header. diff --git a/nitrostack/templates/flight-booking/app_module.py b/nitrostack/templates/flight-booking/app_module.py new file mode 100644 index 0000000..2b35803 --- /dev/null +++ b/nitrostack/templates/flight-booking/app_module.py @@ -0,0 +1,30 @@ +from nitrostack import module, ConfigModule, OAuthModule +from modules.flights.flights_module import FlightsModule +from health.system_health import SystemHealthCheck +import os + +@module( + name="app", + imports=[ + ConfigModule.for_root( + env_file_path=".env", + defaults={"RESOURCE_URI": "https://mcplocal", "PORT": "8000"} + ), + OAuthModule.for_root( + resource_uri=os.environ.get("RESOURCE_URI", "https://mcplocal"), + authorization_servers=[os.environ.get("AUTH_SERVER_URL", "https://dev-5dt0utuk315713tjm.us.auth0.com")], + scopes_supported=["read", "write", "admin"], + token_introspection_endpoint=os.environ.get("INTROSPECTION_ENDPOINT"), + token_introspection_client_id=os.environ.get("INTROSPECTION_CLIENT_ID"), + token_introspection_client_secret=os.environ.get("INTROSPECTION_CLIENT_SECRET"), + audience=os.environ.get("TOKEN_AUDIENCE"), + issuer=os.environ.get("TOKEN_ISSUER") + ), + FlightsModule + ], + controllers=[], + providers=[SystemHealthCheck], + exports=[] +) +class AppModule: + pass diff --git a/nitrostack/templates/flight-booking/guards/oauth_guard.py b/nitrostack/templates/flight-booking/guards/oauth_guard.py new file mode 100644 index 0000000..03c3a07 --- /dev/null +++ b/nitrostack/templates/flight-booking/guards/oauth_guard.py @@ -0,0 +1,14 @@ +from nitrostack import ExecutionContext + +def create_scope_guard(required_scopes: list): + class ScopeGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + user_scopes = getattr(context.auth, "scopes", []) + missing_scopes = [s for s in required_scopes if s not in user_scopes] + if missing_scopes: + raise ValueError( + f"Insufficient scope. Required: {', '.join(required_scopes)}. " + f"Missing: {', '.join(missing_scopes)}" + ) + return True + return ScopeGuard diff --git a/nitrostack/templates/flight-booking/health/system_health.py b/nitrostack/templates/flight-booking/health/system_health.py new file mode 100644 index 0000000..1e1a3bb --- /dev/null +++ b/nitrostack/templates/flight-booking/health/system_health.py @@ -0,0 +1,11 @@ +import time +from nitrostack import health_check + +class SystemHealthCheck: + def __init__(self): + self.start_time = time.time() + + @health_check("system") + def check_system(self) -> bool: + uptime = time.time() - self.start_time + return uptime >= 0 diff --git a/scratch/test-flight/main.py b/nitrostack/templates/flight-booking/main.py similarity index 100% rename from scratch/test-flight/main.py rename to nitrostack/templates/flight-booking/main.py diff --git a/nitrostack/templates/flight-booking/modules/flights/booking_tools.py b/nitrostack/templates/flight-booking/modules/flights/booking_tools.py new file mode 100644 index 0000000..48104e6 --- /dev/null +++ b/nitrostack/templates/flight-booking/modules/flights/booking_tools.py @@ -0,0 +1,96 @@ +from nitrostack import injectable, tool, widget, use_guards, OAuthGuard, ExecutionContext +from services.duffel_service import DuffelService +from guards.oauth_guard import create_scope_guard +from pydantic import BaseModel, Field +import json + +class CreateOrderInput(BaseModel): + offerId: str = Field(description="The offer ID to book") + passengers: str = Field(description="JSON string containing array of passenger objects. Each passenger must have: title (mr/ms/mrs/miss/dr), givenName (first name), familyName (last name), gender (M/F), bornOn (YYYY-MM-DD), email, phoneNumber.") + +class OrderDetailsInput(BaseModel): + orderId: str = Field(description="The order ID") + +class SeatMapInput(BaseModel): + offerId: str = Field(description="The offer ID to get seats for") + +class CancelOrderInput(BaseModel): + orderId: str = Field(description="The order ID to cancel") + +@injectable(deps=[DuffelService]) +class BookingTools: + def __init__(self, service: DuffelService): + self.service = service + + @tool( + name="create_order", + title="Create Order", + description="Create a flight order with hold (no payment required). Collect passenger details first.", + input_schema=CreateOrderInput + ) + @use_guards(OAuthGuard, create_scope_guard(["write"])) + @widget("order-summary") + async def create_order(self, input: CreateOrderInput, context: ExecutionContext) -> dict: + context.logger.info(f"Creating flight order for offer {input.offerId}") + try: + passengers_array = json.loads(input.passengers) + except Exception: + raise ValueError("Invalid passengers JSON format") + + passengers = [] + for p in passengers_array: + passengers.append({ + "title": p.get("title", "mr"), + "given_name": p.get("givenName"), + "family_name": p.get("familyName"), + "gender": p.get("gender", "M"), + "born_on": p.get("bornOn"), + "email": p.get("email"), + "phone_number": p.get("phoneNumber") + }) + + order_params = { + "selectedOffers": [input.offerId], + "passengers": passengers + } + res = await self.service.create_order(order_params) + return res + + @tool( + name="get_order_details", + title="Get Order Details", + description="Get detailed information about an order.", + input_schema=OrderDetailsInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("order-summary") + async def get_order_details(self, input: OrderDetailsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching details for order {input.orderId}") + res = await self.service.get_order(input.orderId) + return res + + @tool( + name="get_seat_map", + title="Get Seat Map", + description="Get available seats for a flight offer to allow seat selection.", + input_schema=SeatMapInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("seat-selection") + async def get_seat_map(self, input: SeatMapInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching seat map for offer {input.offerId}") + res = await self.service.get_seats_for_offer(input.offerId) + return {"offerId": input.offerId, "cabins": res} + + @tool( + name="cancel_order", + title="Cancel Order", + description="Cancel a flight order and request refund if applicable.", + input_schema=CancelOrderInput + ) + @use_guards(OAuthGuard, create_scope_guard(["write"])) + @widget("order-cancellation") + async def cancel_order(self, input: CancelOrderInput, context: ExecutionContext) -> dict: + context.logger.info(f"Cancelling order {input.orderId}") + res = await self.service.cancel_order(input.orderId) + return res diff --git a/nitrostack/templates/flight-booking/modules/flights/flights_module.py b/nitrostack/templates/flight-booking/modules/flights/flights_module.py new file mode 100644 index 0000000..a7b3d9f --- /dev/null +++ b/nitrostack/templates/flight-booking/modules/flights/flights_module.py @@ -0,0 +1,15 @@ +from nitrostack import module +from modules.flights.flights_tools import FlightTools +from modules.flights.booking_tools import BookingTools +from modules.flights.flights_prompts import FlightPrompts +from modules.flights.flights_resources import FlightResources +from services.duffel_service import DuffelService + +@module( + name="flights", + controllers=[FlightTools, BookingTools, FlightPrompts, FlightResources], + providers=[DuffelService], + exports=[DuffelService] +) +class FlightsModule: + pass diff --git a/nitrostack/templates/flight-booking/modules/flights/flights_prompts.py b/nitrostack/templates/flight-booking/modules/flights/flights_prompts.py new file mode 100644 index 0000000..de02027 --- /dev/null +++ b/nitrostack/templates/flight-booking/modules/flights/flights_prompts.py @@ -0,0 +1,21 @@ +from nitrostack import injectable, prompt, ExecutionContext +from services.duffel_service import DuffelService + +@injectable(deps=[DuffelService]) +class FlightPrompts: + def __init__(self, service: DuffelService): + self.service = service + + @prompt( + name="flight_search_assistant", + description="An AI assistant specialized in helping users search for flights and make booking decisions." + ) + async def flight_search_assistant(self, args: dict, context: ExecutionContext) -> str: + return "You are a professional flight booking assistant. Help the user search for flights using search_flights, search_airports, and assist in booking holds." + + @prompt( + name="flight_comparison", + description="Compare multiple flight offers and provide recommendations." + ) + async def flight_comparison(self, args: dict, context: ExecutionContext) -> str: + return "Compare the provided flight offer details, check layover times, durations, and pricing, and give the user a summary of best options." diff --git a/nitrostack/templates/flight-booking/modules/flights/flights_resources.py b/nitrostack/templates/flight-booking/modules/flights/flights_resources.py new file mode 100644 index 0000000..f542dd0 --- /dev/null +++ b/nitrostack/templates/flight-booking/modules/flights/flights_resources.py @@ -0,0 +1,30 @@ +from nitrostack import injectable, resource, ExecutionContext +from services.duffel_service import DuffelService + +@injectable(deps=[DuffelService]) +class FlightResources: + def __init__(self, service: DuffelService): + self.service = service + + @resource( + uri="flight://popular-routes", + name="Popular Flight Routes", + description="Information about popular routes and pricing", + mime_type="application/json" + ) + async def popular_routes(self, context: ExecutionContext) -> dict: + return { + "routes": [ + {"route": "JFK -> LAX", "price": "$200-400"}, + {"route": "LHR -> JFK", "price": "$400-800"} + ] + } + + @resource( + uri="flight://booking-guide", + name="Flight Booking Guide", + description="Guide on searching and booking flights", + mime_type="text/markdown" + ) + async def booking_guide(self, context: ExecutionContext) -> str: + return "# Flight Booking Guide\n\n1. Search flights.\n2. Collect passenger info.\n3. Create order hold." diff --git a/nitrostack/templates/flight-booking/modules/flights/flights_tools.py b/nitrostack/templates/flight-booking/modules/flights/flights_tools.py new file mode 100644 index 0000000..9facf1d --- /dev/null +++ b/nitrostack/templates/flight-booking/modules/flights/flights_tools.py @@ -0,0 +1,77 @@ +from nitrostack import injectable, tool, widget, use_guards, OAuthGuard, ExecutionContext +from services.duffel_service import DuffelService +from guards.oauth_guard import create_scope_guard +from pydantic import BaseModel, Field +from typing import Optional + +class SearchFlightsInput(BaseModel): + origin: str = Field(description="Origin airport IATA code (e.g., 'JFK', 'LHR')") + destination: str = Field(description="Destination airport IATA code (e.g., 'LAX', 'CDG')") + departureDate: str = Field(description="Departure date in YYYY-MM-DD format") + returnDate: Optional[str] = Field(default=None, description="Return date in YYYY-MM-DD format for round trip") + adults: int = Field(default=1, description="Number of adult passengers (18+)") + cabinClass: str = Field(default="economy", description="Preferred cabin class (economy, premium_economy, business, first)") + +class FlightDetailsInput(BaseModel): + offerId: str = Field(description="The flight offer ID from search results") + +class AirportSearchInput(BaseModel): + query: str = Field(description="The search query for airports (e.g., 'London', 'New York')") + +@injectable(deps=[DuffelService]) +class FlightTools: + def __init__(self, service: DuffelService): + self.service = service + + @tool( + name="search_flights", + title="Search Flights", + description="Search for flight offers based on origin, destination, dates, and preferences.", + input_schema=SearchFlightsInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("flight-search-results") + async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Searching flights from {input.origin} to {input.destination}") + res = await self.service.search_flights(input.model_dump()) + return res + + @tool( + name="get_flight_details", + title="Get Flight Details", + description="Get detailed information about a specific flight offer including baggage allowance, conditions.", + input_schema=FlightDetailsInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("flight-details") + async def get_flight_details(self, input: FlightDetailsInput, context: ExecutionContext) -> dict: + context.logger.info(f"Fetching flight details for offer {input.offerId}") + res = await self.service.get_offer(input.offerId) + return res + + @tool( + name="search_airports", + title="Search Airports", + description="Search for airports by query string.", + input_schema=AirportSearchInput + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + @widget("airport-search") + async def search_airports(self, input: AirportSearchInput, context: ExecutionContext) -> dict: + context.logger.info(f"Searching airports for query: {input.query}") + places = await self.service.search_airports(input.query) + return { + "query": input.query, + "results": places + } + + @tool( + name="get_airlines", + title="Get Airlines", + description="Get list of common airlines." + ) + @use_guards(OAuthGuard, create_scope_guard(["read"])) + async def get_airlines(self, context: ExecutionContext) -> dict: + context.logger.info("Fetching common airlines") + res = await self.service.get_airlines() + return {"airlines": res} diff --git a/scratch/test-flight/requirements.txt b/nitrostack/templates/flight-booking/requirements.txt similarity index 100% rename from scratch/test-flight/requirements.txt rename to nitrostack/templates/flight-booking/requirements.txt diff --git a/nitrostack/templates/flight-booking/services/duffel_service.py b/nitrostack/templates/flight-booking/services/duffel_service.py new file mode 100644 index 0000000..0fe6e85 --- /dev/null +++ b/nitrostack/templates/flight-booking/services/duffel_service.py @@ -0,0 +1,282 @@ +import os +import sys +import json +import urllib.request +import urllib.parse +from typing import List, Dict, Any, Optional +from nitrostack import injectable + +@injectable() +class DuffelService: + def __init__(self): + self.api_key = os.environ.get("DUFFEL_API_KEY") + self.is_mock = not self.api_key or self.api_key.startswith("your-") or len(self.api_key) < 5 + + def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if self.is_mock: + raise ValueError("Duffel API key is not configured. Running in mock mode.") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Duffel-Version": "v1", + "Content-Type": "application/json" + } + url = f"https://api.duffel.com{path}" + req_data = json.dumps({"data": data}).encode("utf-8") if data else None + + req = urllib.request.Request(url, data=req_data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + res_payload = json.loads(response.read().decode("utf-8")) + return res_payload.get("data", {}) + except Exception as e: + sys.stderr.write(f"Duffel API Request failed: {e}\n") + sys.stderr.flush() + raise e + + async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "orq_mock123456", + "offers": [ + { + "id": "off_mock123456", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": params["origin"], "name": "Origin Airport", "city_name": "Origin City"}, + "destination": {"iata_code": params["destination"], "name": "Dest Airport", "city_name": "Dest City"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_outbound", + "origin": {"iata_code": params["origin"]}, + "destination": {"iata_code": params["destination"]}, + "departing_at": f"{params['departureDate']}T08:00:00Z", + "arriving_at": f"{params['departureDate']}T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + ] + } + + slices = [ + { + "origin": params["origin"], + "destination": params["destination"], + "departure_date": params["departureDate"] + } + ] + if params.get("returnDate"): + slices.append({ + "origin": params["destination"], + "destination": params["origin"], + "departure_date": params["returnDate"] + }) + + duffel_params = { + "slices": slices, + "passengers": [{"type": "adult"} for _ in range(params.get("adults", 1))], + "cabin_class": params.get("cabinClass", "economy"), + "return_offers": True + } + res = self._request("POST", "/offer_requests", duffel_params) + return { + "id": res.get("id"), + "offers": res.get("offers", []), + "passengers": res.get("passengers", []), + "slices": res.get("slices", []) + } + + async def get_offer(self, offer_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": offer_id, + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"}, + "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_mock", + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + return self._request("GET", f"/offers/{offer_id}") + + async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + { + "cabin_class": "economy", + "rows": [ + { + "row_number": 10, + "sections": [ + { + "elements": [ + { + "type": "seat", + "id": "seat_10a", + "designator": "10A", + "available_services": [{"total_amount": "25.00", "total_currency": "USD"}], + "disclosures": ["window"] + }, + { + "type": "seat", + "id": "seat_10b", + "designator": "10B", + "available_services": [{"total_amount": "0.00", "total_currency": "USD"}], + "disclosures": ["middle"] + } + ] + } + ] + } + ] + } + ] + res = self._request("GET", f"/seat_maps?offer_id={offer_id}") + return res if isinstance(res, list) else [] + + async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "ord_mock123456", + "status": "held", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "booking_reference": "ABCXYZ", + "passengers": [ + { + "id": f"pax_{idx}", + "given_name": p["given_name"], + "family_name": p["family_name"], + "type": "adult" + } for idx, p in enumerate(params["passengers"]) + ], + "slices": [ + { + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "duration": "PT6H30M", + "segments": [ + { + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z" + } + ] + } + ] + } + + order_payload = { + "selected_offers": params["selectedOffers"], + "passengers": params["passengers"], + "type": "hold" + } + return self._request("POST", "/orders", order_payload) + + async def get_order(self, order_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": order_id, + "status": "held", + "total_amount": "450.00", + "total_currency": "USD", + "booking_reference": "ABCXYZ", + "created_at": "2026-06-25T12:00:00Z", + "expires_at": "2026-12-31T12:00:00Z", + "passengers": [ + { + "id": "pax_0", + "given_name": "John", + "family_name": "Doe", + "type": "adult", + "email": "john@example.com", + "phone_number": "+1234567890" + } + ], + "slices": [ + { + "id": "sli_mock", + "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"}, + "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"}, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_mock", + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "departing_at": "2026-07-15T08:00:00Z", + "arriving_at": "2026-07-15T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"} + } + ] + } + ] + } + return self._request("GET", f"/orders/{order_id}") + + async def cancel_order(self, order_id: str) -> Dict[str, Any]: + if self.is_mock: + return { + "id": "ocr_mock123456", + "refund_amount": "450.00", + "refund_currency": "USD", + "confirmed_at": "2026-06-25T12:30:00Z" + } + cancel_payload = {"order_id": order_id} + return self._request("POST", "/order_cancellations", cancel_payload) + + async def get_airlines(self) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + {"iata_code": "AA", "name": "American Airlines"}, + {"iata_code": "DL", "name": "Delta Air Lines"}, + {"iata_code": "UA", "name": "United Airlines"}, + {"iata_code": "BA", "name": "British Airways"} + ] + res = self._request("GET", "/airlines") + return res if isinstance(res, list) else [] + + async def search_airports(self, query: str) -> List[Dict[str, Any]]: + if self.is_mock: + return [ + { + "id": "arp_lhr_gb", + "name": "London Heathrow Airport", + "iata_code": "LHR", + "icao_code": "EGLL", + "city_name": "London", + "type": "airport", + "latitude": 51.4700, + "longitude": -0.4543, + "time_zone": "Europe/London" + } + ] + res = self._request("GET", f"/places?type=airport&query={urllib.parse.quote(query)}") + return res if isinstance(res, list) else [] diff --git a/nitrostack/templates/flight-booking/src/widgets/app/airport-search/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/airport-search/page.tsx new file mode 100644 index 0000000..8e72661 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/airport-search/page.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; + +/** + * Airport Search Widget + * + * Compact display of airport search results with IATA codes and locations. + */ + +interface AirportResult { + id: string; + name: string; + iataCode: string; + icaoCode?: string; + cityName?: string; + type: string; + latitude?: number; + longitude?: number; + timeZone?: string; +} + +interface AirportSearchData { + query: string; + results: AirportResult[]; +} + +export default function AirportSearch() { + const { getToolOutput } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + + const isDark = theme === 'dark'; + + const getTypeIcon = (type: string) => { + const icons: Record = { + 'airport': '✈️', + 'city': '🏙️', + 'station': '🚉', + 'bus_station': '🚌', + 'heliport': '🚁' + }; + return icons[type] || '📍'; + }; + + if (!data) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ 🔍 +

+ Airport Search +

+
+

+ Searching: "{data.query}" +

+
+ {data.results.length} result{data.results.length !== 1 ? 's' : ''} +
+
+ + {/* Results */} + {data.results.length > 0 ? ( +
+ {data.results.map((airport) => ( +
{ + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = isDark + ? '0 4px 12px rgba(59, 159, 255, 0.2)' + : '0 4px 12px rgba(59, 159, 255, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = isDark + ? '0 2px 8px rgba(0,0,0,0.3)' + : '0 4px 12px rgba(0,0,0,0.1)'; + }}> +
+ {/* Left side */} +
+
+ + {getTypeIcon(airport.type)} + +
+

+ {airport.name} +

+ {airport.cityName && ( +

+ 📍 {airport.cityName} +

+ )} +
+
+ + {/* Additional details */} +
+ {airport.timeZone && ( +
+ 🕐 + {airport.timeZone} +
+ )} + {airport.latitude && airport.longitude && ( +
+ 🌍 + {airport.latitude.toFixed(2)}, {airport.longitude.toFixed(2)} +
+ )} +
+
+ + {/* Right side - Codes */} +
+ {/* IATA Code */} +
+ {airport.iataCode || 'N/A'} +
+ + {/* ICAO Code */} + {airport.icaoCode && ( +
+ ICAO: {airport.icaoCode} +
+ )} + + {/* Type badge */} +
+ {airport.type.replace('_', ' ')} +
+
+
+
+ ))} +
+ ) : ( +
+
🔍
+
+ No airports found +
+
+ Try a different city or airport code +
+
+ )} + + {/* Help text */} +
+ 💡 Tip: Use the IATA code (3-letter) for flight searches +
+
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/flight-details/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/flight-details/page.tsx new file mode 100644 index 0000000..d30f730 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/flight-details/page.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; + +/** + * Flight Details Widget - Compact view with segments, baggage, and fare conditions + */ + +interface Segment { + id: string; + origin: string; + destination: string; + departingAt: string; + arrivingAt: string; + duration: string; + airline: { name: string; code: string; flightNumber: string }; + aircraft?: string; +} + +interface Slice { + origin: { code: string; name: string; city: string }; + destination: { code: string; name: string; city: string }; + duration: string; + segments: Segment[]; +} + +interface FlightDetailsData { + id: string; + totalAmount: string; + totalCurrency: string; + slices: Slice[]; + passengers: Array<{ id: string; type: string; baggageAllowance?: Array<{ type: string; quantity: number }> }>; + conditions: { + refundBeforeDeparture: { allowed: boolean; penaltyAmount?: string; penaltyCurrency?: string }; + changeBeforeDeparture: { allowed: boolean; penaltyAmount?: string; penaltyCurrency?: string }; + }; +} + +export default function FlightDetails() { + const { getToolOutput } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + + const isDark = theme === 'dark'; + + const formatDuration = (duration: string) => { + const match = duration.match(/PT(\d+H)?(\d+M)?/); + if (!match) return duration; + const hours = match[1] ? parseInt(match[1]) : 0; + const minutes = match[2] ? parseInt(match[2]) : 0; + return `${hours}h ${minutes}m`; + }; + + const formatTime = (isoString: string) => { + const date = new Date(isoString); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); + }; + + const formatDate = (isoString: string) => { + const date = new Date(isoString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + if (!data) { + return
Loading...
; + } + + return ( +
+ {/* Header */} +
+
+
+

Flight Details

+

+ Offer ID: {data.id} +

+
+
+
+ {data.totalCurrency} {parseFloat(data.totalAmount).toFixed(2)} +
+
Total Price
+
+
+
+ + {/* Flight Itinerary */} + {data.slices.map((slice, sliceIndex) => ( +
+

+ ✈️ + {slice.origin.city} → {slice.destination.city} +

+ +
+ Duration: {formatDuration(slice.duration)} +
+ + {/* Segments */} + {slice.segments.map((segment) => ( +
+
+
+ {segment.airline.name} {segment.airline.flightNumber} +
+
+ {formatDuration(segment.duration)} +
+
+ +
+
+
{formatTime(segment.departingAt)}
+
+ {segment.origin} +
+
+ {formatDate(segment.departingAt)} +
+
+ +
+
+
+ +
+
{formatTime(segment.arrivingAt)}
+
+ {segment.destination} +
+
+ {formatDate(segment.arrivingAt)} +
+
+
+ + {segment.aircraft && ( +
+ ✈️ {segment.aircraft} +
+ )} +
+ ))} +
+ ))} + + {/* Baggage */} +
+

+ 🧳 + Baggage Allowance +

+ {data.passengers.map((passenger, index) => ( +
+
+ Passenger {index + 1} ({passenger.type}) +
+ {passenger.baggageAllowance && passenger.baggageAllowance.length > 0 ? ( +
+ {passenger.baggageAllowance.map((bag, bagIndex) => ( + + {bag.quantity}x {bag.type.replace('_', ' ')} + + ))} +
+ ) : ( +
+ No baggage info +
+ )} +
+ ))} +
+ + {/* Fare Conditions */} + {data.conditions && ( +
+

+ 📋 + Fare Conditions +

+
+
+
+ {data.conditions.refundBeforeDeparture?.allowed ? '✓' : '✗'} Refund Before Departure +
+ {data.conditions.refundBeforeDeparture?.penaltyAmount && ( +
+ Penalty: {data.conditions.refundBeforeDeparture.penaltyCurrency} {data.conditions.refundBeforeDeparture.penaltyAmount} +
+ )} +
+ +
+
+ {data.conditions.changeBeforeDeparture?.allowed ? '✓' : '✗'} Changes Before Departure +
+ {data.conditions.changeBeforeDeparture?.penaltyAmount && ( +
+ Penalty: {data.conditions.changeBeforeDeparture.penaltyCurrency} {data.conditions.changeBeforeDeparture.penaltyAmount} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/flight-search-results/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/flight-search-results/page.tsx new file mode 100644 index 0000000..60fd80a --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/flight-search-results/page.tsx @@ -0,0 +1,378 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; + +/** + * Flight Search Results Widget + * + * Modern, compact display of flight search results with Nitrocloud branding. + */ + +interface FlightSegment { + origin: string; + destination: string; + departureTime: string; + arrivalTime: string; + duration: string; + stops: number; + airline: string; + flightNumber: string; +} + +interface FlightOffer { + id: string; + totalAmount: string; + totalCurrency: string; + outbound: FlightSegment; + return?: FlightSegment; + fareType: string; + refundable: boolean; + changeable: boolean; +} + +interface FlightSearchData { + searchParams: { + origin: string; + destination: string; + departureDate: string; + returnDate?: string; + passengers: { + adults: number; + children: number; + infants: number; + }; + cabinClass: string; + }; + totalOffers: number; + offers: FlightOffer[]; +} + +export default function FlightSearchResults() { + const { getToolOutput, callTool } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + const isDark = theme === 'dark'; + + const handleFlightClick = async (offerId: string) => { + try { + await callTool('get_flight_details', { offerId }); + } catch (error) { + console.error('Failed to get flight details:', error); + } + }; + + const formatDuration = (duration: string) => { + const match = duration.match(/PT(\d+H)?(\d+M)?/); + if (!match) return duration; + const hours = match[1] ? parseInt(match[1]) : 0; + const minutes = match[2] ? parseInt(match[2]) : 0; + return `${hours}h ${minutes}m`; + }; + + const formatTime = (isoString: string) => { + return new Date(isoString).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + }; + + const getAirlineInitials = (name: string) => { + return name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase(); + }; + + if (!data?.searchParams) { + return ( +
+
⚠️
+
+ Invalid Data +
+
+ Flight search data is missing +
+
+ ); + } + + const totalPassengers = (data.searchParams.passengers?.adults || 0) + + (data.searchParams.passengers?.children || 0) + + (data.searchParams.passengers?.infants || 0); + + const FlightSegment = ({ segment, label }: { segment: FlightSegment; label: string }) => ( +
+
+ {label === 'Outbound' ? '✈️' : '🔄'} + {label} +
+ +
+
+
+ {formatTime(segment.departureTime)} +
+
+ {segment.origin} +
+
+ +
+
+ {formatDuration(segment.duration)} +
+
+ {segment.stops > 0 && ( +
+ )} +
+
+ {segment.stops === 0 ? 'Direct' : `${segment.stops} stop${segment.stops > 1 ? 's' : ''}`} +
+
+ +
+
+ {formatTime(segment.arrivalTime)} +
+
+ {segment.destination} +
+
+
+
+ ); + + return ( +
+ {/* Header */} +
+
+
+ {data.searchParams.origin} + ✈️ + {data.searchParams.destination} +
+ +
+ {data.totalOffers} flights +
+
+ +
+
+ 📅 + + {formatDate(data.searchParams.departureDate)} + {data.searchParams.returnDate && ` - ${formatDate(data.searchParams.returnDate)}`} + +
+
+ 👥 + {totalPassengers} pax +
+
+ 💺 + {(data.searchParams.cabinClass || 'economy').replace('_', ' ')} +
+
+
+ + {/* Flight Offers */} + {data.offers.length > 0 ? ( +
+ {data.offers.map((offer) => ( +
handleFlightClick(offer.id)} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = isDark + ? '0 4px 12px rgba(59, 159, 255, 0.2)' + : '0 4px 12px rgba(59, 159, 255, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = isDark + ? '0 2px 8px rgba(0,0,0,0.3)' + : '0 2px 8px rgba(0,0,0,0.1)'; + }}> + {/* Offer Header */} +
+
+
+ {getAirlineInitials(offer.outbound.airline)} +
+
+
+ {offer.outbound.airline} +
+
+ {offer.outbound.flightNumber} +
+
+
+ +
+
+ {offer.totalCurrency} {parseFloat(offer.totalAmount).toFixed(0)} +
+
+ Total +
+
+
+ + {/* Flight Segments */} + + {offer.return && } + + {/* Badges */} +
+ + {offer.refundable ? '✓ Refundable' : '✗ Non-refundable'} + + {offer.changeable && ( + ✓ Changeable + )} + {offer.fareType} +
+
+ ))} +
+ ) : ( +
+
✈️
+
+ No flights found. Try adjusting your search. +
+
+ )} +
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/globals.css b/nitrostack/templates/flight-booking/src/widgets/app/globals.css new file mode 100644 index 0000000..ca47c6b --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/globals.css @@ -0,0 +1,167 @@ +/* Nitrocloud Widget Styles - Professional Theme */ + +:root { + /* Professional Color Palette */ + --primary: #3B82F6; + --primary-hover: #2563EB; + --secondary: #6B7280; + --secondary-hover: #4B5563; + + --success: #10B981; + --warning: #F59E0B; + --error: #EF4444; + --info: #3B82F6; + + /* Light Theme */ + --bg-primary: #FFFFFF; + --bg-secondary: #F9FAFB; + --bg-tertiary: #F3F4F6; + --bg-elevated: #FFFFFF; + + --text-primary: #111827; + --text-secondary: #6B7280; + --text-muted: #9CA3AF; + + --border-color: #E5E7EB; + --border-hover: #D1D5DB; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.dark { + /* Dark Theme */ + --bg-primary: #111827; + --bg-secondary: #1F2937; + --bg-tertiary: #374151; + --bg-elevated: #1F2937; + + --text-primary: #F9FAFB; + --text-secondary: #D1D5DB; + --text-muted: #9CA3AF; + + --border-color: #374151; + --border-hover: #4B5563; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + +/* Reset */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Card Styles */ +.card { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + transition: all 0.2s ease; + box-shadow: var(--shadow-sm); +} + +.card:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-md); +} + +/* Button Styles */ +.btn-primary { + background: var(--primary); + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-size: 14px; +} + +.btn-primary:hover { + background: var(--primary-hover); + box-shadow: var(--shadow-md); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 10px 20px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-size: 14px; +} + +.btn-secondary:hover { + background: var(--bg-tertiary); + border-color: var(--border-hover); +} + +/* Badge Styles */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; +} + +.badge-success { + background: rgba(16, 185, 129, 0.1); + color: var(--success); + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.badge-warning { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.badge-error { + background: rgba(239, 68, 68, 0.1); + color: var(--error); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.badge-info { + background: rgba(59, 130, 246, 0.1); + color: var(--info); + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.dark .badge-success { + background: rgba(16, 185, 129, 0.2); + color: #6EE7B7; +} + +.dark .badge-warning { + background: rgba(245, 158, 11, 0.2); + color: #FCD34D; +} + +.dark .badge-error { + background: rgba(239, 68, 68, 0.2); + color: #FCA5A5; +} + +.dark .badge-info { + background: rgba(59, 130, 246, 0.2); + color: #93C5FD; +} \ No newline at end of file diff --git a/nitrostack/templates/flight-booking/src/widgets/app/layout.tsx b/nitrostack/templates/flight-booking/src/widgets/app/layout.tsx new file mode 100644 index 0000000..fe7980c --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/layout.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { WidgetLayout } from '@nitrostack/widgets'; +import './globals.css'; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/order-cancellation/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/order-cancellation/page.tsx new file mode 100644 index 0000000..d87d851 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/order-cancellation/page.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; + +/** + * Order Cancellation Widget - Cancellation confirmation with refund info + */ + +interface CancellationData { + orderId: string; + cancellationId: string; + status: string; + refundAmount?: string; + refundCurrency?: string; + confirmedAt: string; + message: string; +} + +export default function OrderCancellation() { + const { getToolOutput } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + + const isDark = theme === 'dark'; + + const formatDateTime = (isoString: string) => { + return new Date(isoString).toLocaleString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + if (!data) { + return
Loading...
; + } + + const hasRefund = data.refundAmount && parseFloat(data.refundAmount) > 0; + + return ( +
+
+ {/* Icon */} +
+
+ {hasRefund ? '💰' : '✗'} +
+
+ + {/* Title */} +

+ Booking Cancelled +

+ +

+ {data.message} +

+ + {/* Cancellation Details */} +
+
+ {/* Order ID */} +
+
+ Order ID +
+
+ {data.orderId} +
+
+ + {/* Cancellation ID */} +
+
+ Cancellation Reference +
+
+ {data.cancellationId} +
+
+ + {/* Cancelled On */} +
+
+ Cancelled On +
+
+ {formatDateTime(data.confirmedAt)} +
+
+ + {/* Refund Information */} + {hasRefund ? ( +
+
+
💰
+
+
+ Refund Amount +
+
+ {data.refundCurrency} {parseFloat(data.refundAmount!).toFixed(2)} +
+
+
+
+ Refund will be processed to your original payment method within 5-10 business days. +
+
+ ) : ( +
+
+
ℹ️
+
+ No Refund Available: This booking was non-refundable or outside the cancellation window. +
+
+
+ )} +
+
+ + {/* Status Badge */} +
+
+ ✗ {data.status} +
+
+ + {/* Action Buttons */} +
+ + + +
+ + {/* Help Section */} +
+
+ 💬 + Need Help? +
+
+ Questions about this cancellation? Contact our support team 24/7. +
+ +
+
+
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/order-summary/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/order-summary/page.tsx new file mode 100644 index 0000000..105361b --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/order-summary/page.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; + +/** + * Order Summary Widget - Compact booking confirmation + */ + +interface OrderData { + orderId: string; + status: string; + bookingReference?: string; + totalAmount: string; + totalCurrency: string; + createdAt?: string; + expiresAt?: string; + passengers: Array<{ id: string; name: string; type: string; email?: string }>; + slices: Array<{ + origin: { code: string; city?: string }; + destination: { code: string; city?: string }; + segments?: Array<{ airline: string; flightNumber: string }>; + }>; + message?: string; +} + +export default function OrderSummary() { + const { getToolOutput } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + + const isDark = theme === 'dark'; + + const formatDateTime = (isoString: string) => { + return new Date(isoString).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getStatusIcon = (status: string) => { + return { 'confirmed': '🎉', 'held': '⏱️', 'cancelled': '✗', 'pending': '⋯' }[status.toLowerCase()] || '📋'; + }; + + if (!data) { + return
Loading...
; + } + + return ( +
+ {/* Header */} +
+
+ {getStatusIcon(data.status)} +
+

+ {data.status === 'confirmed' ? 'Booking Confirmed!' : + data.status === 'held' ? 'Order On Hold' : 'Order Summary'} +

+ + {data.bookingReference && ( +
+ Reference: {data.bookingReference} +
+ )} + +
+ {data.status.toUpperCase()} +
+ + {data.message && ( +
+ {data.message} +
+ )} + + {data.expiresAt && data.status === 'held' && ( +
+
+ ⏰ Payment Required +
+
+ Complete before: {formatDateTime(data.expiresAt)} +
+
+ )} +
+ + {/* Order Info */} +
+

Order Information

+
+
+ Order ID: + {data.orderId} +
+
+ Total: + + {data.totalCurrency} {parseFloat(data.totalAmount).toFixed(2)} + +
+ {data.createdAt && ( +
+ Created: + {formatDateTime(data.createdAt)} +
+ )} +
+
+ + {/* Passengers */} +
+

+ 👥 + Passengers ({data.passengers.length}) +

+
+ {data.passengers.map((passenger, index) => ( +
+
+
+ {passenger.name} +
+
+ {passenger.type} +
+ {passenger.email && ( +
+ 📧 {passenger.email} +
+ )} +
+
+ {index + 1} +
+
+ ))} +
+
+ + {/* Flight Itinerary */} +
+

+ ✈️ + Flight Itinerary +

+
+ {data.slices.map((slice, index) => ( +
+
+ {index === 0 ? 'Outbound' : 'Return'} Flight +
+
+
+
+ {slice.origin.code} +
+ {slice.origin.city && ( +
+ {slice.origin.city} +
+ )} +
+ +
+ +
+
+ {slice.destination.code} +
+ {slice.destination.city && ( +
+ {slice.destination.city} +
+ )} +
+
+ + {slice.segments && slice.segments.length > 0 && ( +
+ {slice.segments.map((segment, segIndex) => ( +
+ {segment.airline} {segment.flightNumber} +
+ ))} +
+ )} +
+ ))} +
+
+
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/payment-confirmation/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/payment-confirmation/page.tsx new file mode 100644 index 0000000..0898312 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/payment-confirmation/page.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; + +/** + * Payment Confirmation Widget - Success state with booking details + */ + +interface PaymentData { + orderId: string; + status: string; + totalAmount: string; + totalCurrency: string; + bookingReference?: string; + message?: string; +} + +export default function PaymentConfirmation() { + const { getToolOutput } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + + const isDark = theme === 'dark'; + + if (!data) { + return
Loading...
; + } + + const isConfirmed = data.status === 'confirmed'; + + return ( +
+
+ {/* Success Icon */} +
+
+ {isConfirmed ? '✓' : '💳'} +
+
+ + {/* Title */} +

+ {isConfirmed ? 'Payment Successful!' : 'Complete Payment'} +

+ +

+ {data.message || (isConfirmed ? 'Your booking has been confirmed.' : 'Review and confirm your payment.')} +

+ + {/* Booking Details */} +
+ {data.bookingReference && ( +
+
+ Booking Reference +
+
+ {data.bookingReference} +
+
+ )} + +
+
+ Order ID: + {data.orderId} +
+ +
+ + {isConfirmed ? 'Amount Paid:' : 'Total Amount:'} + + + {data.totalCurrency} {parseFloat(data.totalAmount).toFixed(2)} + +
+
+
+ + {/* Action Buttons */} + {isConfirmed ? ( +
+ + +
+ ) : ( +
+ +
+ 🔒 Your payment is secure and encrypted +
+
+ )} + + {/* Footer Note */} + {isConfirmed && ( +
+ 📱 Important: A confirmation email has been sent to your registered email address. +
+ )} +
+
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/seat-selection/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/seat-selection/page.tsx new file mode 100644 index 0000000..0ae6b06 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/app/seat-selection/page.tsx @@ -0,0 +1,486 @@ +'use client'; + +import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; +import { useState } from 'react'; + +/** + * Seat Selection Widget + * + * Interactive seat map with real-time selection for multiple passengers. + */ + +interface Seat { + id: string; + column: string; + available: boolean; + price?: string; + type: string; +} + +interface Row { + rowNumber: number; + seats: Seat[]; +} + +interface Cabin { + cabinClass: string; + rows: Row[]; +} + +interface SeatMapData { + offerId: string; + cabins: Cabin[]; + message?: string; +} + +export default function SeatSelection() { + const { getToolOutput } = useWidgetSDK(); + const theme = useTheme(); + const data = getToolOutput(); + + const isDark = theme === 'dark'; + const [selectedSeats, setSelectedSeats] = useState>({}); + const [activePassenger, setActivePassenger] = useState(0); + const [hoveredSeat, setHoveredSeat] = useState(null); + + const passengers = [ + { id: 'pax_1', name: 'Passenger 1' }, + { id: 'pax_2', name: 'Passenger 2' } + ]; + + const handleSeatClick = (seatId: string, seat: Seat) => { + if (!seat.available) return; + + const currentPassengerId = passengers[activePassenger].id; + const seatOwner = Object.entries(selectedSeats).find(([_, id]) => id === seatId)?.[0]; + + if (seatOwner && seatOwner !== currentPassengerId) return; + + setSelectedSeats(prev => { + const newSelections = { ...prev }; + if (newSelections[currentPassengerId] === seatId) { + delete newSelections[currentPassengerId]; + } else { + delete newSelections[currentPassengerId]; + newSelections[currentPassengerId] = seatId; + if (activePassenger < passengers.length - 1) { + setTimeout(() => setActivePassenger(activePassenger + 1), 200); + } + } + return newSelections; + }); + }; + + const getSeatStatus = (seatId: string, seat: Seat) => { + if (!seat.available) return 'unavailable'; + const owner = Object.entries(selectedSeats).find(([_, id]) => id === seatId)?.[0]; + if (owner) { + const passengerIndex = passengers.findIndex(p => p.id === owner); + return passengerIndex === activePassenger ? 'selected-active' : 'selected-other'; + } + return 'available'; + }; + + const getSeatColor = (status: string) => { + if (isDark) { + return { + 'available': '#334155', + 'selected-active': '#3B9FFF', + 'selected-other': '#22C55E', + 'unavailable': '#1E293B' + }[status] || '#334155'; + } + return { + 'available': '#E2E8F0', + 'selected-active': '#3B9FFF', + 'selected-other': '#22C55E', + 'unavailable': '#CBD5E1' + }[status] || '#E2E8F0'; + }; + + const calculateTotalPrice = () => { + let total = 0; + Object.values(selectedSeats).forEach(seatId => { + data?.cabins.forEach(cabin => { + cabin.rows.forEach(row => { + const seat = row.seats.find(s => s.id === seatId); + if (seat?.price) total += parseFloat(seat.price); + }); + }); + }); + return total; + }; + + const getSelectedSeatInfo = (passengerId: string) => { + const seatId = selectedSeats[passengerId]; + if (!seatId || !data) return null; + + for (const cabin of data.cabins) { + for (const row of cabin.rows) { + const seat = row.seats.find(s => s.id === seatId); + if (seat) return { seat, row: row.rowNumber }; + } + } + return null; + }; + + if (!data) { + return
Loading...
; + } + + return ( +
+ {/* Header */} +
+
+
+

+ 💺 + Select Seats +

+

+ {data.message || 'Choose seats for all passengers'} +

+
+ + {Object.keys(selectedSeats).length > 0 && ( +
+
Total
+
+ ${calculateTotalPrice().toFixed(2)} +
+
+ )} +
+
+ +
+ {/* Seat Map */} +
+ {/* Front Indicator */} +
+
✈️
+
+ FRONT +
+
+ + {data.cabins.map((cabin, cabinIndex) => ( +
+
+ {cabin.cabinClass.replace('_', ' ')} +
+ +
+ {cabin.rows.map((row) => ( +
+
+ {row.rowNumber} +
+ +
+ {row.seats.map((seat) => { + const status = getSeatStatus(seat.id, seat); + const isHovered = hoveredSeat === seat.id; + + return ( +
handleSeatClick(seat.id, seat)} + onMouseEnter={() => setHoveredSeat(seat.id)} + onMouseLeave={() => setHoveredSeat(null)} + style={{ + width: '40px', + height: '40px', + background: getSeatColor(status), + borderRadius: '6px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: seat.available ? 'pointer' : 'not-allowed', + transition: 'all 0.2s ease', + transform: isHovered && seat.available ? 'scale(1.1)' : 'scale(1)', + border: status === 'selected-active' ? '2px solid #fff' : 'none', + position: 'relative', + opacity: seat.available ? 1 : 0.4 + }} + > + 💺 +
+ {seat.column} +
+ {seat.price && parseFloat(seat.price) > 0 && isHovered && ( +
+ ${seat.price} +
+ )} +
+ ); + })} +
+ +
+ {row.rowNumber} +
+
+ ))} +
+
+ ))} + + {/* Legend */} +
+ {[ + { label: 'Available', color: isDark ? '#334155' : '#E2E8F0' }, + { label: 'Your Seat', color: '#3B9FFF' }, + { label: 'Other', color: '#22C55E' }, + { label: 'Taken', color: isDark ? '#1E293B' : '#CBD5E1' } + ].map(item => ( +
+
+ + {item.label} + +
+ ))} +
+
+ + {/* Sidebar */} +
+ {/* Passengers */} +
+

+ Passengers +

+ +
+ {passengers.map((passenger, index) => { + const seatInfo = getSelectedSeatInfo(passenger.id); + const isActive = activePassenger === index; + + return ( +
setActivePassenger(index)} + className={isActive ? 'nitro-gradient' : ''} + style={{ + padding: '12px', + background: isActive ? undefined : (isDark ? '#0F172A' : '#F8FAFC'), + borderRadius: '8px', + cursor: 'pointer', + transition: 'all 0.2s ease', + border: isActive ? '2px solid #fff' : '2px solid transparent' + }} + > +
+
+
+ {passenger.name} +
+ {seatInfo ? ( +
+ {seatInfo.row}{seatInfo.seat.column} + {seatInfo.seat.price && ` • $${seatInfo.seat.price}`} +
+ ) : ( +
+ No seat +
+ )} +
+ + {seatInfo && ( +
+ ✓ +
+ )} +
+
+ ); + })} +
+
+ + {/* Summary */} +
+

+ Summary +

+ +
+
+ Selected: + + {Object.keys(selectedSeats).length} / {passengers.length} + +
+ +
+ Total: + + ${calculateTotalPrice().toFixed(2)} + +
+
+ + +
+
+
+
+ ); +} diff --git a/nitrostack/templates/flight-booking/src/widgets/next-env.d.ts b/nitrostack/templates/flight-booking/src/widgets/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/nitrostack/templates/flight-booking/src/widgets/next.config.js b/nitrostack/templates/flight-booking/src/widgets/next.config.js new file mode 100644 index 0000000..f35620c --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/next.config.js @@ -0,0 +1,45 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['nitrostack'], + + // Static export for production builds + ...(process.env.NODE_ENV === 'production' && { + output: 'export', + distDir: 'out', + images: { + unoptimized: true, + }, + }), + + // Development optimizations to prevent cache corruption + ...(process.env.NODE_ENV === 'development' && { + // Use memory cache instead of filesystem cache in dev to avoid stale chunks + webpack: (config, { isServer }) => { + // Disable persistent caching in development to prevent chunk reference errors + if (config.cache && config.cache.type === 'filesystem') { + config.cache = { + type: 'memory', + }; + } + + // Improve cache busting for new files + if (!isServer) { + config.cache = false; // Disable cache completely on client in dev + } + + return config; + }, + + // Disable build activity indicator which can cause issues + devIndicators: { + buildActivity: false, + buildActivityPosition: 'bottom-right', + }, + + // Faster dev server + compress: false, + }), +}; + +export default nextConfig; diff --git a/nitrostack/templates/flight-booking/src/widgets/package.json b/nitrostack/templates/flight-booking/src/widgets/package.json new file mode 100644 index 0000000..6495572 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/package.json @@ -0,0 +1,25 @@ +{ + "name": "flight-booking-widgets", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev -p 3001 --port 3001", + "build": "next build", + "start": "next start -p 3001" + }, + "dependencies": { + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@nitrostack/widgets": "^1", + "@modelcontextprotocol/ext-apps": ">=0.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5" + } +} + diff --git a/nitrostack/templates/flight-booking/src/widgets/tsconfig.json b/nitrostack/templates/flight-booking/src/widgets/tsconfig.json new file mode 100644 index 0000000..2c0ad66 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + diff --git a/nitrostack/templates/flight-booking/src/widgets/widget-manifest.json b/nitrostack/templates/flight-booking/src/widgets/widget-manifest.json new file mode 100644 index 0000000..6dd7c34 --- /dev/null +++ b/nitrostack/templates/flight-booking/src/widgets/widget-manifest.json @@ -0,0 +1,395 @@ +{ + "version": "1.0.0", + "widgets": [ + { + "uri": "/flight-search-results", + "name": "Flight Search Results", + "description": "Displays flight search results with pricing, airlines, and flight details", + "examples": [ + { + "name": "Round Trip Search", + "description": "Shows results for a round trip flight search", + "data": { + "requestId": "orq_example123", + "searchParams": { + "origin": "JFK", + "destination": "LAX", + "departureDate": "2024-03-15", + "returnDate": "2024-03-22", + "passengers": { + "adults": 2, + "children": 0, + "infants": 0 + }, + "cabinClass": "economy" + }, + "totalOffers": 15, + "offers": [ + { + "id": "off_example123", + "totalAmount": "450.00", + "totalCurrency": "USD", + "expiresAt": "2024-03-01T12:00:00Z", + "outbound": { + "origin": "JFK", + "destination": "LAX", + "departureTime": "2024-03-15T08:00:00Z", + "arrivalTime": "2024-03-15T14:30:00Z", + "duration": "PT6H30M", + "stops": 0, + "airline": "American Airlines", + "flightNumber": "AA123", + "segments": [] + }, + "return": { + "origin": "LAX", + "destination": "JFK", + "departureTime": "2024-03-22T16:00:00Z", + "arrivalTime": "2024-03-23T00:30:00Z", + "duration": "PT5H30M", + "stops": 0, + "airline": "American Airlines", + "flightNumber": "AA456", + "segments": [] + }, + "fareType": "Domestic", + "refundable": false, + "changeable": true + } + ], + "message": "Found 15 flight options" + } + } + ], + "tags": [ + "flights", + "search", + "travel", + "booking" + ] + }, + { + "uri": "/flight-details", + "name": "Flight Details", + "description": "Displays comprehensive flight details including segments, baggage, and fare conditions", + "examples": [ + { + "name": "Flight Details Example", + "description": "Shows detailed information for a specific flight offer", + "data": { + "id": "off_example123", + "totalAmount": "450.00", + "totalCurrency": "USD", + "expiresAt": "2024-03-01T12:00:00Z", + "slices": [ + { + "origin": { + "code": "JFK", + "name": "John F. Kennedy International Airport", + "city": "New York" + }, + "destination": { + "code": "LAX", + "name": "Los Angeles International Airport", + "city": "Los Angeles" + }, + "duration": "PT6H30M", + "segments": [ + { + "id": "seg_123", + "origin": "JFK", + "destination": "LAX", + "departingAt": "2024-03-15T08:00:00Z", + "arrivingAt": "2024-03-15T14:30:00Z", + "duration": "PT6H30M", + "airline": { + "name": "American Airlines", + "code": "AA", + "flightNumber": "123" + }, + "aircraft": "Boeing 777-300ER" + } + ] + } + ], + "passengers": [ + { + "id": "pas_123", + "type": "adult", + "fareType": "economy", + "baggageAllowance": [ + { + "type": "checked", + "quantity": 1 + }, + { + "type": "carry_on", + "quantity": 1 + } + ] + } + ], + "conditions": { + "refundBeforeDeparture": { + "allowed": false + }, + "changeBeforeDeparture": { + "allowed": true, + "penaltyAmount": "75.00", + "penaltyCurrency": "USD" + } + }, + "paymentRequirements": { + "requiresInstantPayment": true, + "priceGuaranteeExpiresAt": "2024-03-01T12:00:00Z" + } + } + } + ], + "tags": [ + "flights", + "details", + "travel", + "booking" + ] + }, + { + "uri": "/airport-search", + "name": "Airport Search", + "description": "Displays airport search results with IATA codes and location details", + "examples": [ + { + "name": "London Airports", + "description": "Shows search results for London airports", + "data": { + "query": "London", + "results": [ + { + "id": "arp_lhr_gb", + "name": "Heathrow Airport", + "iataCode": "LHR", + "icaoCode": "EGLL", + "cityName": "London", + "type": "airport", + "latitude": 51.4700, + "longitude": -0.4543, + "timeZone": "Europe/London" + }, + { + "id": "arp_lgw_gb", + "name": "Gatwick Airport", + "iataCode": "LGW", + "icaoCode": "EGKK", + "cityName": "London", + "type": "airport", + "latitude": 51.1537, + "longitude": -0.1821, + "timeZone": "Europe/London" + }, + { + "id": "arp_stn_gb", + "name": "Stansted Airport", + "iataCode": "STN", + "icaoCode": "EGSS", + "cityName": "London", + "type": "airport", + "latitude": 51.8860, + "longitude": 0.2389, + "timeZone": "Europe/London" + } + ] + } + } + ], + "tags": [ + "airports", + "search", + "travel", + "iata" + ] + }, + { + "uri": "/order-summary", + "name": "Order Summary", + "description": "Displays booking confirmation with order details, passenger information, and flight itinerary", + "examples": [ + { + "name": "Confirmed Booking", + "description": "Shows a confirmed flight booking with all details", + "data": { + "orderId": "ord_example123", + "status": "confirmed", + "bookingReference": "ABC123", + "totalAmount": "450.00", + "totalCurrency": "USD", + "createdAt": "2024-03-01T10:00:00Z", + "passengers": [ + { + "id": "pax_1", + "name": "John Doe", + "type": "adult", + "email": "john@example.com" + } + ], + "slices": [ + { + "origin": { + "code": "JFK", + "name": "John F. Kennedy International", + "city": "New York" + }, + "destination": { + "code": "LAX", + "name": "Los Angeles International", + "city": "Los Angeles" + }, + "departureTime": "2024-03-15T08:00:00Z", + "arrivalTime": "2024-03-15T14:30:00Z", + "segments": [] + } + ], + "message": "Your booking has been confirmed!" + } + } + ], + "tags": [ + "booking", + "order", + "confirmation", + "travel" + ] + }, + { + "uri": "/seat-selection", + "name": "Seat Selection", + "description": "Interactive seat map for selecting seats with real-time pricing and availability", + "examples": [ + { + "name": "Economy Cabin Seats", + "description": "Shows available seats in economy cabin", + "data": { + "offerId": "off_example123", + "cabins": [ + { + "cabinClass": "economy", + "rows": [ + { + "rowNumber": 10, + "seats": [ + { + "id": "seat_10a", + "column": "A", + "available": true, + "price": "0", + "currency": "USD", + "type": "window" + }, + { + "id": "seat_10b", + "column": "B", + "available": true, + "price": "0", + "currency": "USD", + "type": "middle" + }, + { + "id": "seat_10c", + "column": "C", + "available": true, + "price": "0", + "currency": "USD", + "type": "aisle" + } + ] + } + ] + } + ], + "message": "Select your preferred seats" + } + } + ], + "tags": [ + "seats", + "selection", + "booking", + "travel" + ] + }, + { + "uri": "/payment-confirmation", + "name": "Payment Confirmation", + "description": "Payment processing and confirmation interface with order summary", + "examples": [ + { + "name": "Payment Success", + "description": "Shows successful payment confirmation", + "data": { + "orderId": "ord_example123", + "status": "confirmed", + "totalAmount": "450.00", + "totalCurrency": "USD", + "bookingReference": "ABC123", + "message": "Payment confirmed successfully!" + } + }, + { + "name": "Payment Pending", + "description": "Shows payment pending state", + "data": { + "orderId": "ord_example456", + "status": "pending", + "totalAmount": "650.00", + "totalCurrency": "USD" + } + } + ], + "tags": [ + "payment", + "confirmation", + "booking", + "checkout" + ] + }, + { + "uri": "/order-cancellation", + "name": "Order Cancellation", + "description": "Order cancellation confirmation with refund information and status", + "examples": [ + { + "name": "Cancellation with Refund", + "description": "Shows successful cancellation with refund", + "data": { + "orderId": "ord_example123", + "cancellationId": "ocr_example123", + "status": "cancelled", + "refundAmount": "450.00", + "refundCurrency": "USD", + "confirmedAt": "2024-03-01T12:00:00Z", + "message": "Order cancelled. Refund of USD 450.00 will be processed." + } + }, + { + "name": "Cancellation without Refund", + "description": "Shows cancellation with no refund", + "data": { + "orderId": "ord_example456", + "cancellationId": "ocr_example456", + "status": "cancelled", + "refundAmount": "0", + "refundCurrency": "USD", + "confirmedAt": "2024-03-01T12:00:00Z", + "message": "Order cancelled. No refund available for this booking." + } + } + ], + "tags": [ + "cancellation", + "refund", + "booking", + "order" + ] + } + ], + "generatedAt": "2025-01-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/scratch/test-flight/.env b/nitrostack/templates/pizzaz/.env similarity index 100% rename from scratch/test-flight/.env rename to nitrostack/templates/pizzaz/.env diff --git a/nitrostack/templates/pizzaz/app_module.py b/nitrostack/templates/pizzaz/app_module.py new file mode 100644 index 0000000..2cfa7d7 --- /dev/null +++ b/nitrostack/templates/pizzaz/app_module.py @@ -0,0 +1,14 @@ +from nitrostack import module, ConfigModule +from modules.pizzaz.pizzaz_module import PizzazModule +from health.system_health import SystemHealthCheck + +@module( + name="app", + imports=[ + ConfigModule.for_root(env_file_path=".env", defaults={"PORT": "8000"}), + PizzazModule + ], + providers=[SystemHealthCheck] +) +class AppModule: + pass diff --git a/nitrostack/templates/pizzaz/health/system_health.py b/nitrostack/templates/pizzaz/health/system_health.py new file mode 100644 index 0000000..1e1a3bb --- /dev/null +++ b/nitrostack/templates/pizzaz/health/system_health.py @@ -0,0 +1,11 @@ +import time +from nitrostack import health_check + +class SystemHealthCheck: + def __init__(self): + self.start_time = time.time() + + @health_check("system") + def check_system(self) -> bool: + uptime = time.time() - self.start_time + return uptime >= 0 diff --git a/scratch/test-food/main.py b/nitrostack/templates/pizzaz/main.py similarity index 100% rename from scratch/test-food/main.py rename to nitrostack/templates/pizzaz/main.py diff --git a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_data.py b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_data.py new file mode 100644 index 0000000..f40e1af --- /dev/null +++ b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_data.py @@ -0,0 +1,85 @@ +PIZZA_SHOPS = [ + { + "id": "tonys-pizza", + "name": "Tony's New York Pizza", + "description": "Authentic New York-style pizza with a crispy thin crust and fresh toppings", + "address": "123 Main St, San Francisco, CA 94102", + "coords": [-122.4194, 37.7749], + "rating": 4.5, + "reviews": 342, + "priceLevel": 2, + "cuisine": ["Italian", "Pizza", "New York Style"], + "hours": {"open": "11:00 AM", "close": "10:00 PM"}, + "phone": "(415) 555-0123", + "website": "https://tonyspizza.example.com", + "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", + "specialties": ["Margherita", "Pepperoni", "White Pizza"], + "openNow": True, + }, + { + "id": "bella-napoli", + "name": "Bella Napoli", + "description": "Traditional Neapolitan pizza baked in a wood-fired oven", + "address": "456 Market St, San Francisco, CA 94103", + "coords": [-122.4089, 37.7858], + "rating": 4.8, + "reviews": 521, + "priceLevel": 3, + "cuisine": ["Italian", "Pizza", "Neapolitan"], + "hours": {"open": "12:00 PM", "close": "11:00 PM"}, + "phone": "(415) 555-0456", + "website": "https://bellanapoli.example.com", + "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", + "specialties": ["Marinara", "Quattro Formaggi", "Diavola"], + "openNow": True, + }, + { + "id": "slice-house", + "name": "The Slice House", + "description": "Casual spot for creative pizza slices and craft beer", + "address": "789 Valencia St, San Francisco, CA 94110", + "coords": [-122.4216, 37.7599], + "rating": 4.3, + "reviews": 289, + "priceLevel": 1, + "cuisine": ["Pizza", "American", "Casual"], + "hours": {"open": "11:30 AM", "close": "9:00 PM"}, + "phone": "(415) 555-0789", + "image": "https://images.unsplash.com/photo-1565299624946-b28f40a0ae38", + "specialties": ["BBQ Chicken", "Hawaiian", "Veggie Supreme"], + "openNow": True, + }, + { + "id": "pizzeria-delfina", + "name": "Pizzeria Delfina", + "description": "Upscale pizzeria with seasonal ingredients and house-made mozzarella", + "address": "3611 18th St, San Francisco, CA 94110", + "coords": [-122.4252, 37.7615], + "rating": 4.6, + "reviews": 678, + "priceLevel": 3, + "cuisine": ["Italian", "Pizza", "Fine Dining"], + "hours": {"open": "5:00 PM", "close": "10:00 PM"}, + "phone": "(415) 555-3611", + "website": "https://pizzeriadelfina.example.com", + "image": "https://images.unsplash.com/photo-1571997478779-2adcbbe9ab2f", + "specialties": ["Salsiccia", "Funghi", "Prosciutto e Rucola"], + "openNow": False, + }, + { + "id": "golden-boy", + "name": "Golden Boy Pizza", + "description": "North Beach institution serving thick Sicilian-style squares", + "address": "542 Green St, San Francisco, CA 94133", + "coords": [-122.4102, 37.7999], + "rating": 4.4, + "reviews": 445, + "priceLevel": 1, + "cuisine": ["Pizza", "Sicilian", "Italian"], + "hours": {"open": "11:30 AM", "close": "11:30 PM"}, + "phone": "(415) 555-0542", + "image": "https://images.unsplash.com/photo-1628840042765-356cda07504e", + "specialties": ["Clam & Garlic", "Pesto", "Classic Cheese"], + "openNow": True, + }, +] diff --git a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_module.py b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_module.py new file mode 100644 index 0000000..8718534 --- /dev/null +++ b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_module.py @@ -0,0 +1,12 @@ +from nitrostack import module +from modules.pizzaz.pizzaz_tools import PizzazTools +from modules.pizzaz.pizzaz_service import PizzazService + +@module( + name="pizzaz", + controllers=[PizzazTools], + providers=[PizzazService], + exports=[PizzazService] +) +class PizzazModule: + pass diff --git a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py new file mode 100644 index 0000000..bb4323f --- /dev/null +++ b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py @@ -0,0 +1,33 @@ +from nitrostack import injectable +from modules.pizzaz.pizzaz_data import PIZZA_SHOPS + +@injectable() +class PizzazService: + def get_all_shops(self): + return PIZZA_SHOPS + + def get_shop_by_id(self, shop_id: str): + for shop in PIZZA_SHOPS: + if shop["id"] == shop_id: + return shop + return None + + def get_shops_filtered(self, filters: dict): + shops = PIZZA_SHOPS + if filters.get("openNow"): + shops = [shop for shop in shops if shop["openNow"]] + if filters.get("minRating") is not None: + shops = [shop for shop in shops if shop["rating"] >= filters["minRating"]] + if filters.get("maxPrice") is not None: + shops = [shop for shop in shops if shop["priceLevel"] <= filters["maxPrice"]] + if filters.get("cuisine"): + cuisine_lower = filters["cuisine"].lower() + shops = [ + shop for shop in shops + if any(cuisine_lower in c.lower() for c in shop["cuisine"]) + ] + return shops + + def get_top_rated_shops(self, limit: int = 5): + sorted_shops = sorted(PIZZA_SHOPS, key=lambda x: x["rating"], reverse=True) + return sorted_shops[:limit] diff --git a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py new file mode 100644 index 0000000..d4f4520 --- /dev/null +++ b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py @@ -0,0 +1,83 @@ +from nitrostack import injectable, tool, widget, ExecutionContext +from pydantic import BaseModel, Field +from typing import Literal, Optional +from modules.pizzaz.pizzaz_service import PizzazService + +class ShowMapInput(BaseModel): + filter: Literal["open_now", "top_rated", "all"] = Field(default="all", description="Filter to apply") + +class ShowListInput(BaseModel): + openNow: Optional[bool] = Field(default=None, description="Show only shops that are currently open") + minRating: Optional[float] = Field(default=None, description="Minimum rating (1-5)") + maxPrice: Optional[float] = Field(default=None, description="Maximum price level (1-3)") + +class ShowShopInput(BaseModel): + shopId: str = Field(description="ID of the pizza shop to display") + +@injectable(deps=[PizzazService]) +class PizzazTools: + def __init__(self, service: PizzazService): + self.service = service + + @tool( + name="show_pizza_map", + description="Display an interactive map of pizza shops in San Francisco", + input_schema=ShowMapInput + ) + @widget("pizza-map") + async def show_pizza_map(self, input: ShowMapInput, context: ExecutionContext) -> dict: + context.logger.info(f"Showing pizza map with filter: {input.filter}") + if input.filter == "open_now": + shops = self.service.get_shops_filtered({"openNow": True}) + elif input.filter == "top_rated": + shops = self.service.get_top_rated_shops() + else: + shops = self.service.get_all_shops() + + return { + "shops": shops, + "filter": input.filter, + "totalShops": len(shops) + } + + @tool( + name="show_pizza_list", + description="Display a list of pizza shops with details, ratings, and filters", + input_schema=ShowListInput + ) + @widget("pizza-list") + async def show_pizza_list(self, input: ShowListInput, context: ExecutionContext) -> dict: + context.logger.info("Showing pizza list") + filters = {} + if input.openNow is not None: + filters["openNow"] = input.openNow + if input.minRating is not None: + filters["minRating"] = input.minRating + if input.maxPrice is not None: + filters["maxPrice"] = input.maxPrice + + shops = self.service.get_shops_filtered(filters) + return { + "shops": shops, + "filters": { + "openNow": input.openNow, + "minRating": input.minRating, + "maxPrice": input.maxPrice + }, + "totalShops": len(shops) + } + + @tool( + name="show_pizza_shop", + description="Display detailed page for a single pizza shop, including menu, ratings, hours, and photos", + input_schema=ShowShopInput + ) + @widget("pizza-shop") + async def show_pizza_shop(self, input: ShowShopInput, context: ExecutionContext) -> dict: + context.logger.info(f"Showing pizza shop: {input.shopId}") + shop = self.service.get_shop_by_id(input.shopId) + if not shop: + raise ValueError(f"Pizza shop not found: {input.shopId}") + return { + "shop": shop + } diff --git a/scratch/test-food/requirements.txt b/nitrostack/templates/pizzaz/requirements.txt similarity index 100% rename from scratch/test-food/requirements.txt rename to nitrostack/templates/pizzaz/requirements.txt diff --git a/nitrostack/templates/pizzaz/src/widgets/app/layout.tsx b/nitrostack/templates/pizzaz/src/widgets/app/layout.tsx new file mode 100644 index 0000000..100a41a --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/app/layout.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { WidgetLayout } from '@nitrostack/widgets'; +import 'mapbox-gl/dist/mapbox-gl.css'; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/nitrostack/templates/pizzaz/src/widgets/app/pizza-list/page.tsx b/nitrostack/templates/pizzaz/src/widgets/app/pizza-list/page.tsx new file mode 100644 index 0000000..fbabe86 --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/app/pizza-list/page.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useTheme, useWidgetState, useMaxHeight, useWidgetSDK } from '@nitrostack/widgets'; + +// Disable static generation - this is a dynamic widget +export const dynamic = 'force-dynamic'; +import { PizzaCard } from '../../components/PizzaCard'; +import { SlidersHorizontal } from 'lucide-react'; +import { useState } from 'react'; + +interface PizzaShop { + id: string; + name: string; + description: string; + address: string; + coords: [number, number]; + rating: number; + reviews: number; + priceLevel: 1 | 2 | 3; + cuisine: string[]; + hours: { open: string; close: string }; + phone: string; + website?: string; + image: string; + specialties: string[]; + openNow: boolean; +} + +interface WidgetData { + shops: PizzaShop[]; + filters: any; + totalShops: number; +} + +export default function PizzaListWidget() { + const theme = useTheme(); + const maxHeight = useMaxHeight(); + const isDark = theme === 'dark'; + + const { isReady, getToolOutput, callTool } = useWidgetSDK(); + + // Access tool output + const data = getToolOutput(); + + console.log('🍕 PizzaListWidget render:', { isReady, hasData: !!data, data }); + + // Persistent state for view mode and favorites + const [state, setState] = useWidgetState<{ + viewMode: 'grid' | 'list'; + favorites: string[]; + sortBy: 'rating' | 'name' | 'price'; + }>(() => ({ + viewMode: 'grid', + favorites: [], + sortBy: 'rating', + })); + + const [showFilters, setShowFilters] = useState(false); + + if (!data) { + return ( +
+ Loading pizza shops... {isReady ? '(SDK ready but no data)' : '(waiting for SDK)'} +
+ ); + } + + // Check if shops array exists + if (!data.shops || !Array.isArray(data.shops)) { + console.error('❌ Invalid data structure:', data); + return ( +
+ Error: Invalid data structure. Expected shops array. +
+                    {JSON.stringify(data, null, 2)}
+                
+
+ ); + } + + const toggleFavorite = (shopId: string) => { + const favorites = state?.favorites || []; + const newFavorites = favorites.includes(shopId) + ? favorites.filter(id => id !== shopId) + : [...favorites, shopId]; + + setState({ ...state, favorites: newFavorites }); + }; + + const handleShopClick = async (shopId: string) => { + // Call the show_pizza_shop tool to show shop details + await callTool('show_pizza_shop', { shopId }); + }; + + // Sort shops + let sortedShops = [...data.shops]; + switch (state?.sortBy) { + case 'rating': + sortedShops.sort((a, b) => b.rating - a.rating); + break; + case 'name': + sortedShops.sort((a, b) => a.name.localeCompare(b.name)); + break; + case 'price': + sortedShops.sort((a, b) => a.priceLevel - b.priceLevel); + break; + } + + return ( +
+ {/* Header */} +
+
+
+

+ 🍕 Pizza Shops +

+

+ {data.totalShops} shops found +

+
+ + {/* Sort and Filter Controls */} +
+ + + +
+
+ + {/* Filters */} + {showFilters && ( +
+

+ Filters coming soon... +

+
+ )} + + {/* Favorites Count */} + {state?.favorites && state.favorites.length > 0 && ( +
+ ❤️ {state.favorites.length} favorite{state.favorites.length !== 1 ? 's' : ''} +
+ )} +
+ + {/* Horizontal Scrolling Shop Cards */} +
+
+ {sortedShops.map(shop => ( +
+ handleShopClick(shop.id)} + /> +
+ ))} +
+
+ + {/* Footer */} +
+ Powered by NitroStack • Theme: {theme || 'light'} • Scroll horizontally → +
+
+ ); +} diff --git a/nitrostack/templates/pizzaz/src/widgets/app/pizza-map/page.tsx b/nitrostack/templates/pizzaz/src/widgets/app/pizza-map/page.tsx new file mode 100644 index 0000000..f1187d4 --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/app/pizza-map/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useTheme, useWidgetState, useMaxHeight, useDisplayMode, useWidgetSDK } from '@nitrostack/widgets'; + +// Disable static generation - this is a dynamic widget +export const dynamic = 'force-dynamic'; +import { useEffect, useRef, useState } from 'react'; +import mapboxgl from 'mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { CompactShopCard } from '../../components/CompactShopCard'; +import { Maximize2 } from 'lucide-react'; + +interface PizzaShop { + id: string; + name: string; + description: string; + address: string; + coords: [number, number]; + rating: number; + reviews: number; + priceLevel: 1 | 2 | 3; + cuisine: string[]; + hours: { open: string; close: string }; + phone: string; + website?: string; + image: string; + specialties: string[]; + openNow: boolean; +} + +interface WidgetData { + shops: PizzaShop[]; + filter: string; + totalShops: number; +} + +export default function PizzaMapWidget() { + const theme = useTheme(); + const maxHeight = useMaxHeight(); + const displayMode = useDisplayMode(); + const isDark = theme === 'dark'; + const mapContainer = useRef(null); + const map = useRef(null); + const [selectedShop, setSelectedShop] = useState(null); + + const { isReady, getToolOutput, callTool, requestFullscreen } = useWidgetSDK(); + + // Access tool output + const data = getToolOutput(); + + // Persistent state + const [state, setState] = useWidgetState<{ + favorites: string[]; + }>(() => ({ + favorites: [], + })); + + useEffect(() => { + if (!mapContainer.current || !data || map.current) return; + + // Initialize Mapbox + const initMap = async () => { + try { + // Set your Mapbox token here + mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || 'YOUR_MAPBOX_TOKEN'; + + const mapInstance = new mapboxgl.Map({ + container: mapContainer.current!, + style: isDark ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/streets-v12', + center: data.shops[0]?.coords || [-122.4194, 37.7749], + zoom: 12, + }); + + // Add markers + data.shops.forEach(shop => { + const el = document.createElement('div'); + el.className = 'marker'; + el.style.backgroundImage = 'url(https://docs.mapbox.com/mapbox-gl-js/assets/custom_marker.png)'; + el.style.width = '30px'; + el.style.height = '40px'; + el.style.backgroundSize = '100%'; + el.style.cursor = 'pointer'; + + el.addEventListener('click', () => { + setSelectedShop(shop); + }); + + new mapboxgl.Marker(el) + .setLngLat(shop.coords) + .addTo(mapInstance); + }); + + // Fit bounds to show all markers + if (data.shops.length > 1) { + const bounds = new mapboxgl.LngLatBounds(); + data.shops.forEach(shop => bounds.extend(shop.coords)); + mapInstance.fitBounds(bounds, { padding: 50 }); + } + + map.current = mapInstance; + } catch (error) { + console.error('Failed to load Mapbox:', error); + } + }; + + initMap(); + + return () => { + if (map.current) { + map.current.remove(); + } + }; + }, [data, isDark]); + + if (!data) { + return ( +
+ Loading map... {isReady ? '(SDK ready but no data)' : '(waiting for SDK)'} +
+ ); + } + + const handleShopClick = async (shopId: string) => { + // Call the show_pizza_shop tool to show shop details + await callTool('show_pizza_shop', { shopId }); + }; + + const requestFullscreenMode = async () => { + await requestFullscreen(); + }; + + return ( +
+ {/* Map Container - Full Screen */} +
+ + {/* Enlarge Button */} + + + {/* Overlay Shop Cards - Bottom */} +
+
+ {data.shops.map(shop => ( +
+ { + setSelectedShop(shop); + handleShopClick(shop.id); + }} + isDark={isDark} + /> +
+ ))} +
+
+
+ ); +} diff --git a/nitrostack/templates/pizzaz/src/widgets/app/pizza-shop/page.tsx b/nitrostack/templates/pizzaz/src/widgets/app/pizza-shop/page.tsx new file mode 100644 index 0000000..4f2a731 --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/app/pizza-shop/page.tsx @@ -0,0 +1,374 @@ +'use client'; + +import { useTheme, useMaxHeight, useWidgetSDK } from '@nitrostack/widgets'; + +// Disable static generation - this is a dynamic widget +export const dynamic = 'force-dynamic'; +import { Star, MapPin, Phone, Globe, Clock, Heart, Share2, Navigation } from 'lucide-react'; + +interface PizzaShop { + id: string; + name: string; + description: string; + address: string; + coords: [number, number]; + rating: number; + reviews: number; + priceLevel: 1 | 2 | 3; + cuisine: string[]; + hours: { open: string; close: string }; + phone: string; + website?: string; + image: string; + specialties: string[]; + openNow: boolean; +} + +interface WidgetData { + shop: PizzaShop; + relatedShops: PizzaShop[]; +} + +export default function PizzaShopWidget() { + const theme = useTheme(); + const maxHeight = useMaxHeight(); + const isDark = theme === 'dark'; + const { isReady, getToolOutput, openExternal } = useWidgetSDK(); + + // Access tool output + const data = getToolOutput(); + + if (!data) { + return ( +
+ Loading shop details... {isReady ? '(SDK ready but no data)' : '(waiting for SDK)'} +
+ ); + } + + const { shop, relatedShops } = data; + const priceSymbol = '$'.repeat(shop.priceLevel); + + const openMaps = () => { + const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(shop.address)}`; + openExternal(url); + }; + + const callPhone = () => { + openExternal(`tel:${shop.phone}`); + }; + + const visitWebsite = () => { + if (shop.website) { + openExternal(shop.website); + } + }; + + return ( +
+ {/* Hero Image */} +
+ {shop.name} +
+ + {/* Shop Name Overlay */} +
+

+ {shop.name} +

+
+
+ + + {shop.rating} + + + ({shop.reviews} reviews) + +
+ {priceSymbol} + {shop.openNow && ( + + Open Now + + )} +
+
+
+ + {/* Content */} +
+ {/* Description */} +

+ {shop.description} +

+ + {/* Cuisine Tags */} +
+

+ Cuisine +

+
+ {shop.cuisine.map(c => ( + + {c} + + ))} +
+
+ + {/* Specialties */} +
+

+ Specialties +

+
+ {shop.specialties.map(s => ( + + 🍕 {s} + + ))} +
+
+ + {/* Contact Info */} +
+

+ Contact & Hours +

+ + {/* Address */} +
+ +
+

+ {shop.address} +

+ +
+
+ + {/* Phone */} +
+ + +
+ + {/* Website */} + {shop.website && ( +
+ + +
+ )} + + {/* Hours */} +
+ + + {shop.hours.open} - {shop.hours.close} + +
+
+ + {/* Related Shops */} + {relatedShops.length > 0 && ( +
+

+ You Might Also Like +

+
+ {relatedShops.map(related => ( +
+ {related.name} +
+

+ {related.name} +

+
+ + + {related.rating} + +
+

+ {related.description} +

+
+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/nitrostack/templates/pizzaz/src/widgets/components/CompactShopCard.tsx b/nitrostack/templates/pizzaz/src/widgets/components/CompactShopCard.tsx new file mode 100644 index 0000000..7799ba9 --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/components/CompactShopCard.tsx @@ -0,0 +1,144 @@ +'use client'; + +interface PizzaShop { + id: string; + name: string; + description: string; + address: string; + coords: [number, number]; + rating: number; + reviews: number; + priceLevel: 1 | 2 | 3; + cuisine: string[]; + hours: { open: string; close: string }; + phone: string; + website?: string; + image: string; + specialties: string[]; + openNow: boolean; +} + +interface CompactShopCardProps { + shop: PizzaShop; + isSelected: boolean; + onClick: () => void; + isDark?: boolean; +} + +export function CompactShopCard({ shop, isSelected, onClick, isDark = true }: CompactShopCardProps) { + const priceSymbol = '$'.repeat(shop.priceLevel); + + return ( +
{ + if (!isSelected) { + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; + } + }} + > + {/* Image */} + {shop.name} + + {/* Info */} +
+ {/* Name */} +

+ {shop.name} +

+ + {/* Description */} +

+ {shop.description} +

+ + {/* Rating & Price */} +
+
+ + {shop.rating} +
+ + {priceSymbol} + {shop.openNow && ( + <> + + + Open Now + + + )} +
+
+
+ ); +} diff --git a/nitrostack/templates/pizzaz/src/widgets/components/PizzaCard.tsx b/nitrostack/templates/pizzaz/src/widgets/components/PizzaCard.tsx new file mode 100644 index 0000000..7821977 --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/components/PizzaCard.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useTheme, useWidgetState, useMaxHeight, useDisplayMode } from '@nitrostack/widgets'; +import { Star, MapPin, Phone, Globe, Clock } from 'lucide-react'; + +interface PizzaShop { + id: string; + name: string; + description: string; + address: string; + coords: [number, number]; + rating: number; + reviews: number; + priceLevel: 1 | 2 | 3; + cuisine: string[]; + hours: { open: string; close: string }; + phone: string; + website?: string; + image: string; + specialties: string[]; + openNow: boolean; +} + +interface PizzaCardProps { + shop: PizzaShop; + onSelect?: (shop: PizzaShop) => void; + isFavorite?: boolean; + onToggleFavorite?: (shopId: string) => void; +} + +export function PizzaCard({ shop, onSelect, isFavorite, onToggleFavorite }: PizzaCardProps) { + const theme = useTheme(); + const isDark = theme === 'dark'; + + const priceSymbol = '$'.repeat(shop.priceLevel); + + return ( +
onSelect?.(shop)} + style={{ + background: isDark ? '#1a1a1a' : '#ffffff', + border: `1px solid ${isDark ? '#333' : '#e5e7eb'}`, + borderRadius: '12px', + overflow: 'hidden', + cursor: onSelect ? 'pointer' : 'default', + transition: 'all 0.2s', + boxShadow: isDark ? '0 2px 8px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.1)', + }} + onMouseEnter={(e) => { + if (onSelect) { + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = isDark + ? '0 4px 12px rgba(0,0,0,0.4)' + : '0 4px 12px rgba(0,0,0,0.15)'; + } + }} + onMouseLeave={(e) => { + if (onSelect) { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = isDark + ? '0 2px 8px rgba(0,0,0,0.3)' + : '0 2px 8px rgba(0,0,0,0.1)'; + } + }} + > + {/* Image */} +
+ {shop.name} + {shop.openNow && ( +
+ Open Now +
+ )} + {onToggleFavorite && ( + + )} +
+ + {/* Content */} +
+

+ {shop.name} +

+ + {/* Rating & Price */} +
+
+ + + {shop.rating} + + + ({shop.reviews}) + +
+ + {priceSymbol} + +
+ + {/* Description */} +

+ {shop.description} +

+ + {/* Cuisine Tags */} +
+ {shop.cuisine.slice(0, 3).map(c => ( + + {c} + + ))} +
+ + {/* Address */} +
+ + + {shop.address} + +
+ + {/* Hours */} +
+ + + {shop.hours.open} - {shop.hours.close} + +
+
+
+ ); +} diff --git a/nitrostack/templates/pizzaz/src/widgets/next.config.js b/nitrostack/templates/pizzaz/src/widgets/next.config.js new file mode 100644 index 0000000..f35620c --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/next.config.js @@ -0,0 +1,45 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['nitrostack'], + + // Static export for production builds + ...(process.env.NODE_ENV === 'production' && { + output: 'export', + distDir: 'out', + images: { + unoptimized: true, + }, + }), + + // Development optimizations to prevent cache corruption + ...(process.env.NODE_ENV === 'development' && { + // Use memory cache instead of filesystem cache in dev to avoid stale chunks + webpack: (config, { isServer }) => { + // Disable persistent caching in development to prevent chunk reference errors + if (config.cache && config.cache.type === 'filesystem') { + config.cache = { + type: 'memory', + }; + } + + // Improve cache busting for new files + if (!isServer) { + config.cache = false; // Disable cache completely on client in dev + } + + return config; + }, + + // Disable build activity indicator which can cause issues + devIndicators: { + buildActivity: false, + buildActivityPosition: 'bottom-right', + }, + + // Faster dev server + compress: false, + }), +}; + +export default nextConfig; diff --git a/nitrostack/templates/pizzaz/src/widgets/package.json b/nitrostack/templates/pizzaz/src/widgets/package.json new file mode 100644 index 0000000..1e97ecf --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/package.json @@ -0,0 +1,31 @@ +{ + "name": "pizzaz-widgets", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev -p 3001 --port 3001", + "build": "next build", + "start": "next start -p 3001" + }, + "dependencies": { + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@nitrostack/widgets": "^1", + "@modelcontextprotocol/ext-apps": ">=0.1.0", + "mapbox-gl": "^3.0.1", + "framer-motion": "^10.16.16", + "lucide-react": "^0.294.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/mapbox-gl": "^3.0.0", + "typescript": "^5", + "tailwindcss": "^3.4.0", + "postcss": "^8.4.32", + "autoprefixer": "^10.4.16" + } +} \ No newline at end of file diff --git a/nitrostack/templates/pizzaz/src/widgets/tsconfig.json b/nitrostack/templates/pizzaz/src/widgets/tsconfig.json new file mode 100644 index 0000000..2c0ad66 --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + diff --git a/nitrostack/templates/pizzaz/src/widgets/widget-manifest.json b/nitrostack/templates/pizzaz/src/widgets/widget-manifest.json new file mode 100644 index 0000000..b7f81be --- /dev/null +++ b/nitrostack/templates/pizzaz/src/widgets/widget-manifest.json @@ -0,0 +1,253 @@ +{ + "version": "1.0.0", + "widgets": [ + { + "uri": "/pizza-map", + "name": "Pizza Map", + "description": "Interactive map showing pizza shop locations", + "examples": [ + { + "name": "All Shops", + "description": "Display all pizza shops on the map", + "data": { + "shops": [ + { + "id": "tonys-pizza", + "name": "Tony's New York Pizza", + "description": "Authentic New York-style pizza with a crispy thin crust", + "address": "1570 Stockton St, San Francisco, CA 94133", + "coords": [ + -122.4106, + 37.8006 + ], + "rating": 4.5, + "reviews": 1250, + "priceLevel": 2, + "cuisine": [ + "Italian", + "Pizza", + "New York Style" + ], + "hours": { + "open": "11:00 AM", + "close": "10:00 PM" + }, + "phone": "(415) 835-9888", + "website": "https://tonyspizzasf.com", + "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", + "specialties": [ + "Margherita", + "Pepperoni", + "White Pizza" + ], + "openNow": true + }, + { + "id": "bella-napoli", + "name": "Bella Napoli", + "description": "Traditional Neapolitan pizza baked in a wood-fired oven", + "address": "3854 Geary Blvd, San Francisco, CA 94118", + "coords": [ + -122.4603, + 37.7808 + ], + "rating": 4.7, + "reviews": 890, + "priceLevel": 3, + "cuisine": [ + "Italian", + "Pizza", + "Neapolitan" + ], + "hours": { + "open": "12:00 PM", + "close": "9:00 PM" + }, + "phone": "(415) 221-0305", + "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", + "specialties": [ + "Marinara", + "Quattro Formaggi", + "Prosciutto e Funghi" + ], + "openNow": true + } + ], + "filter": "all", + "totalShops": 2 + } + } + ], + "tags": [ + "map", + "location", + "interactive" + ] + }, + { + "uri": "/pizza-list", + "name": "Pizza List", + "description": "List view of pizza shops with filtering and sorting", + "examples": [ + { + "name": "All Shops List", + "description": "Display all shops in a list", + "data": { + "shops": [ + { + "id": "tonys-pizza", + "name": "Tony's New York Pizza", + "description": "Authentic New York-style pizza with a crispy thin crust", + "address": "1570 Stockton St, San Francisco, CA 94133", + "coords": [ + -122.4106, + 37.8006 + ], + "rating": 4.5, + "reviews": 1250, + "priceLevel": 2, + "cuisine": [ + "Italian", + "Pizza", + "New York Style" + ], + "hours": { + "open": "11:00 AM", + "close": "10:00 PM" + }, + "phone": "(415) 835-9888", + "website": "https://tonyspizzasf.com", + "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", + "specialties": [ + "Margherita", + "Pepperoni", + "White Pizza" + ], + "openNow": true + }, + { + "id": "bella-napoli", + "name": "Bella Napoli", + "description": "Traditional Neapolitan pizza baked in a wood-fired oven", + "address": "3854 Geary Blvd, San Francisco, CA 94118", + "coords": [ + -122.4603, + 37.7808 + ], + "rating": 4.7, + "reviews": 890, + "priceLevel": 3, + "cuisine": [ + "Italian", + "Pizza", + "Neapolitan" + ], + "hours": { + "open": "12:00 PM", + "close": "9:00 PM" + }, + "phone": "(415) 221-0305", + "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", + "specialties": [ + "Marinara", + "Quattro Formaggi", + "Prosciutto e Funghi" + ], + "openNow": true + } + ], + "filters": {}, + "totalShops": 2 + } + } + ], + "tags": [ + "list", + "filter", + "sort" + ] + }, + { + "uri": "/pizza-shop", + "name": "Pizza Shop Details", + "description": "Detailed information about a specific pizza shop", + "examples": [ + { + "name": "Shop Detail", + "description": "Show details for a specific shop", + "data": { + "shop": { + "id": "tonys-pizza", + "name": "Tony's New York Pizza", + "description": "Authentic New York-style pizza with a crispy thin crust", + "address": "1570 Stockton St, San Francisco, CA 94133", + "coords": [ + -122.4106, + 37.8006 + ], + "rating": 4.5, + "reviews": 1250, + "priceLevel": 2, + "cuisine": [ + "Italian", + "Pizza", + "New York Style" + ], + "hours": { + "open": "11:00 AM", + "close": "10:00 PM" + }, + "phone": "(415) 835-9888", + "website": "https://tonyspizzasf.com", + "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", + "specialties": [ + "Margherita", + "Pepperoni", + "White Pizza" + ], + "openNow": true + }, + "relatedShops": [ + { + "id": "bella-napoli", + "name": "Bella Napoli", + "description": "Traditional Neapolitan pizza baked in a wood-fired oven", + "address": "3854 Geary Blvd, San Francisco, CA 94118", + "coords": [ + -122.4603, + 37.7808 + ], + "rating": 4.7, + "reviews": 890, + "priceLevel": 3, + "cuisine": [ + "Italian", + "Pizza", + "Neapolitan" + ], + "hours": { + "open": "12:00 PM", + "close": "9:00 PM" + }, + "phone": "(415) 221-0305", + "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", + "specialties": [ + "Marinara", + "Quattro Formaggi", + "Prosciutto e Funghi" + ], + "openNow": true + } + ] + } + } + ], + "tags": [ + "detail", + "info", + "contact" + ] + } + ], + "generatedAt": "2025-12-03T00:00:00.000Z" +} \ No newline at end of file diff --git a/scratch/test-food/.env b/nitrostack/templates/starter/.env similarity index 100% rename from scratch/test-food/.env rename to nitrostack/templates/starter/.env diff --git a/nitrostack/templates/starter/app_module.py b/nitrostack/templates/starter/app_module.py new file mode 100644 index 0000000..029a7a7 --- /dev/null +++ b/nitrostack/templates/starter/app_module.py @@ -0,0 +1,14 @@ +from nitrostack import module, ConfigModule +from modules.calculator.calculator_module import CalculatorModule +from health.system_health import SystemHealthCheck + +@module( + name="app", + imports=[ + ConfigModule.for_root(env_file_path=".env", defaults={"PORT": "8000"}), + CalculatorModule + ], + providers=[SystemHealthCheck] +) +class AppModule: + pass diff --git a/nitrostack/templates/starter/health/system_health.py b/nitrostack/templates/starter/health/system_health.py new file mode 100644 index 0000000..1e1a3bb --- /dev/null +++ b/nitrostack/templates/starter/health/system_health.py @@ -0,0 +1,11 @@ +import time +from nitrostack import health_check + +class SystemHealthCheck: + def __init__(self): + self.start_time = time.time() + + @health_check("system") + def check_system(self) -> bool: + uptime = time.time() - self.start_time + return uptime >= 0 diff --git a/nitrostack/templates/starter/main.py b/nitrostack/templates/starter/main.py new file mode 100644 index 0000000..6de69ef --- /dev/null +++ b/nitrostack/templates/starter/main.py @@ -0,0 +1,10 @@ +import asyncio +from nitrostack import McpApplicationFactory +from app_module import AppModule + +async def main(): + app = await McpApplicationFactory.create(AppModule) + await app.start() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nitrostack/templates/starter/modules/calculator/calculator_module.py b/nitrostack/templates/starter/modules/calculator/calculator_module.py new file mode 100644 index 0000000..b908f45 --- /dev/null +++ b/nitrostack/templates/starter/modules/calculator/calculator_module.py @@ -0,0 +1,11 @@ +from nitrostack import module +from modules.calculator.calculator_tools import CalculatorTools + +@module( + name="calculator", + controllers=[CalculatorTools], + providers=[], + exports=[] +) +class CalculatorModule: + pass diff --git a/nitrostack/templates/starter/modules/calculator/calculator_tools.py b/nitrostack/templates/starter/modules/calculator/calculator_tools.py new file mode 100644 index 0000000..abd1143 --- /dev/null +++ b/nitrostack/templates/starter/modules/calculator/calculator_tools.py @@ -0,0 +1,117 @@ +import os +import base64 +from nitrostack import injectable, tool, widget, ExecutionContext +from pydantic import BaseModel, Field +from typing import Literal, Optional + +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 ConvertTemperatureInput(BaseModel): + file_name: str = Field(description="Name of the uploaded file") + file_type: str = Field(description="MIME type of the uploaded file") + file_content: str = Field(description="Base64 encoded file content. Will be injected by system.") + value: Optional[float] = Field(default=None, description="Temperature value to convert") + from_unit: Optional[Literal["C", "F"]] = Field(default=None, description="Unit to convert from (C or F)") + to_unit: Optional[Literal["C", "F"]] = Field(default=None, description="Unit to convert to (C or F)") + +@injectable() +class CalculatorTools: + @tool( + name="calculate", + description="Perform basic arithmetic calculations", + input_schema=CalculateInput + ) + @widget("calculator-result") + async def calculate(self, input: CalculateInput, context: ExecutionContext) -> dict: + context.logger.info(f"Performing calculation: {input.operation} on {input.a} and {input.b}") + + result = 0.0 + symbol = "" + + if input.operation == "add": + result = input.a + input.b + symbol = "+" + elif input.operation == "subtract": + result = input.a - input.b + symbol = "-" + elif input.operation == "multiply": + result = input.a * input.b + symbol = "×" + elif input.operation == "divide": + if input.b == 0: + raise ValueError("Cannot divide by zero") + result = input.a / input.b + symbol = "÷" + + return { + "operation": input.operation, + "a": input.a, + "b": input.b, + "result": result, + "expression": f"{input.a} {symbol} {input.b} = {result}" + } + + @tool( + name="convert_temperature", + description="Convert temperature units based on file content or direct input. Supports Celsius (C) and Fahrenheit (F).", + input_schema=ConvertTemperatureInput + ) + async def convert_temperature(self, input: ConvertTemperatureInput, context: ExecutionContext) -> dict: + context.logger.info(f"Processing temperature file: {input.file_name}") + + uploads_dir = os.path.join(os.getcwd(), "uploads") + os.makedirs(uploads_dir, exist_ok=True) + file_path = os.path.join(uploads_dir, input.file_name) + + if input.file_content: + try: + content_str = input.file_content + if "," in content_str: + content_str = content_str.split(",", 1)[1] + data = base64.b64decode(content_str) + with open(file_path, "wb") as f: + f.write(data) + context.logger.info(f"Saved file to {file_path}") + except Exception as e: + context.logger.error(f"Failed to save file: {e}") + + file_stats = { + "name": input.file_name, + "type": input.file_type, + "saved_path": file_path, + "status": "saved" + } + + result = None + message = f"Successfully processed and saved file {input.file_name}" + + if input.value is not None and input.from_unit and input.to_unit: + try: + message += f". Converting {input.value}°{input.from_unit} to {input.to_unit}" + if input.from_unit == input.to_unit: + result = input.value + elif input.from_unit == "C" and input.to_unit == "F": + result = (input.value * 9 / 5) + 32 + elif input.from_unit == "F" and input.to_unit == "C": + result = (input.value - 32) * 5 / 9 + else: + raise ValueError("Unsupported unit conversion") + + if result is not None: + result = round(result, 2) + message += f". Result: {result}°{input.to_unit}" + except Exception as e: + message += f". Conversion failed: {e}" + else: + message += ". No valid conversion parameters detected from manual input or file extraction." + + return { + "status": "success", + "message": message, + "file_info": file_stats, + "conversion_result": {"value": result, "unit": input.to_unit} if result is not None else None, + "original_value": {"value": input.value, "unit": input.from_unit} if input.value is not None else None + } diff --git a/nitrostack.egg-info/top_level.txt b/nitrostack/templates/starter/requirements.txt similarity index 100% rename from nitrostack.egg-info/top_level.txt rename to nitrostack/templates/starter/requirements.txt diff --git a/nitrostack/templates/starter/src/widgets/app/calculator-result/page.tsx b/nitrostack/templates/starter/src/widgets/app/calculator-result/page.tsx new file mode 100644 index 0000000..6d9859e --- /dev/null +++ b/nitrostack/templates/starter/src/widgets/app/calculator-result/page.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useTheme, useWidgetState, useWidgetSDK } from '@nitrostack/widgets'; + +/** + * Example widget demonstrating NitroStack Widget SDK + * This widget is fully compatible with OpenAI ChatGPT + */ + +interface CalculatorData { + operation: string; + a: number; + b: number; + result: number; + expression: string; +} + +export default function CalculatorResult() { + // Use Widget SDK hooks + const theme = useTheme(); + const { getToolOutput } = useWidgetSDK(); + const [state, setState] = useWidgetState<{ viewMode: 'compact' | 'detailed' }>(() => ({ + viewMode: 'detailed' + })); + + // Access tool output from Widget SDK + const data = getToolOutput(); + + if (!data) { + return ( +
+ Loading... +
+ ); + } + + const getOperationColor = (op: string) => { + const colors: Record = { + add: '#10b981', + subtract: '#f59e0b', + multiply: '#3b82f6', + divide: '#8b5cf6' + }; + return colors[op] || '#6b7280'; + }; + + const getOperationIcon = (op: string) => { + const icons: Record = { + add: '➕', + subtract: '➖', + multiply: '✖️', + divide: '➗' + }; + return icons[op] || '🔢'; + }; + + const isDark = theme === 'dark'; + const bgColor = isDark ? '#1a1a1a' : '#ffffff'; + const textColor = isDark ? '#ffffff' : '#000000'; + const mutedColor = isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'; + + return ( +
+
+
+ + {getOperationIcon(data.operation)} + +
+

+ Calculator Result +

+

+ {data.operation.charAt(0).toUpperCase() + data.operation.slice(1)} +

+
+
+ + {/* View mode toggle */} + +
+ +
+
+ {data.expression} +
+ + {state?.viewMode === 'detailed' && ( +
+
+
First
+
{data.a}
+
+
+
Second
+
{data.b}
+
+
+
Result
+
+ {data.result} +
+
+
+ )} +
+ +
+ ✨ NitroStack Calculator + + Theme: {theme || 'light'} | Mode: {state?.viewMode || 'detailed'} + +
+
+ ); +} diff --git a/nitrostack/templates/starter/src/widgets/app/layout.tsx b/nitrostack/templates/starter/src/widgets/app/layout.tsx new file mode 100644 index 0000000..40b03ef --- /dev/null +++ b/nitrostack/templates/starter/src/widgets/app/layout.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { WidgetLayout } from '@nitrostack/widgets'; + + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/nitrostack/templates/starter/src/widgets/next.config.js b/nitrostack/templates/starter/src/widgets/next.config.js new file mode 100644 index 0000000..f35620c --- /dev/null +++ b/nitrostack/templates/starter/src/widgets/next.config.js @@ -0,0 +1,45 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['nitrostack'], + + // Static export for production builds + ...(process.env.NODE_ENV === 'production' && { + output: 'export', + distDir: 'out', + images: { + unoptimized: true, + }, + }), + + // Development optimizations to prevent cache corruption + ...(process.env.NODE_ENV === 'development' && { + // Use memory cache instead of filesystem cache in dev to avoid stale chunks + webpack: (config, { isServer }) => { + // Disable persistent caching in development to prevent chunk reference errors + if (config.cache && config.cache.type === 'filesystem') { + config.cache = { + type: 'memory', + }; + } + + // Improve cache busting for new files + if (!isServer) { + config.cache = false; // Disable cache completely on client in dev + } + + return config; + }, + + // Disable build activity indicator which can cause issues + devIndicators: { + buildActivity: false, + buildActivityPosition: 'bottom-right', + }, + + // Faster dev server + compress: false, + }), +}; + +export default nextConfig; diff --git a/nitrostack/templates/starter/src/widgets/package.json b/nitrostack/templates/starter/src/widgets/package.json new file mode 100644 index 0000000..3f40ae3 --- /dev/null +++ b/nitrostack/templates/starter/src/widgets/package.json @@ -0,0 +1,25 @@ +{ + "name": "calculator-widgets", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev -p 3001 --port 3001", + "build": "next build", + "start": "next start -p 3001" + }, + "dependencies": { + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@nitrostack/widgets": "^1", + "@modelcontextprotocol/ext-apps": ">=0.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5" + } +} + diff --git a/nitrostack/templates/starter/src/widgets/tsconfig.json b/nitrostack/templates/starter/src/widgets/tsconfig.json new file mode 100644 index 0000000..2c0ad66 --- /dev/null +++ b/nitrostack/templates/starter/src/widgets/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + diff --git a/nitrostack/templates/starter/src/widgets/widget-manifest.json b/nitrostack/templates/starter/src/widgets/widget-manifest.json new file mode 100644 index 0000000..1fc23e5 --- /dev/null +++ b/nitrostack/templates/starter/src/widgets/widget-manifest.json @@ -0,0 +1,48 @@ +{ + "version": "1.0.0", + "widgets": [ + { + "uri": "/calculator-result", + "name": "Calculator Result", + "description": "Displays the result of a calculation with operation details", + "examples": [ + { + "name": "Addition Example", + "description": "Shows the result of adding 5 + 3", + "data": { + "operation": "add", + "a": 5, + "b": 3, + "result": 8, + "expression": "5 + 3 = 8" + } + }, + { + "name": "Multiplication Example", + "description": "Shows the result of multiplying 6 × 7", + "data": { + "operation": "multiply", + "a": 6, + "b": 7, + "result": 42, + "expression": "6 × 7 = 42" + } + }, + { + "name": "Division Example", + "description": "Shows the result of dividing 20 ÷ 4", + "data": { + "operation": "divide", + "a": 20, + "b": 4, + "result": 5, + "expression": "20 ÷ 4 = 5" + } + } + ], + "tags": ["calculator", "math", "result"] + } + ], + "generatedAt": "2025-01-01T00:00:00.000Z" +} + diff --git a/nitrostack/testing/__pycache__/__init__.cpython-312.pyc b/nitrostack/testing/__pycache__/__init__.cpython-312.pyc index ee92482..b3e6d2d 100644 Binary files a/nitrostack/testing/__pycache__/__init__.cpython-312.pyc and b/nitrostack/testing/__pycache__/__init__.cpython-312.pyc differ diff --git a/nitrostack/transports/__pycache__/stdio.cpython-312.pyc b/nitrostack/transports/__pycache__/stdio.cpython-312.pyc deleted file mode 100644 index 0c6fa04..0000000 Binary files a/nitrostack/transports/__pycache__/stdio.cpython-312.pyc and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index aa67535..55d134e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitrostack" -version = "0.3.1" +version = "0.3.2" description = "NitroStack Python SDK — A Python-idiomatic port of the NitroStack MCP framework" readme = "README.md" requires-python = ">=3.10" @@ -24,7 +24,7 @@ nitrostack-studio = "nitrostack.studio:start_server_cli" include = ["nitrostack*"] [tool.setuptools.package-data] -nitrostack = ["static/*"] +nitrostack = ["static/*", "templates/**/*"] [tool.ruff] line-length = 100 diff --git a/scratch/test-flight/__pycache__/app_module.cpython-312.pyc b/scratch/test-flight/__pycache__/app_module.cpython-312.pyc deleted file mode 100644 index c4a1ca6..0000000 Binary files a/scratch/test-flight/__pycache__/app_module.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-flight/app_module.py b/scratch/test-flight/app_module.py deleted file mode 100644 index a759148..0000000 --- a/scratch/test-flight/app_module.py +++ /dev/null @@ -1,22 +0,0 @@ -from nitrostack import module -from nitrostack.auth.oauth import OAuthModule -from modules.flight_booking.flight_booking_module import FlightBookingModule - -@module( - name="app", - imports=[ - FlightBookingModule, - # Configure OAuth resource protection - OAuthModule.for_root( - resource_uri="http://localhost:8000/mcp", - authorization_servers=["http://localhost:3000/oauth"], - scopes_supported=["flight:read", "flight:write"], - token_introspection_endpoint="http://localhost:3000/oauth/introspect" - ) - ], - controllers=[], - providers=[], - exports=[] -) -class AppModule: - pass diff --git a/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_module.cpython-312.pyc b/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_module.cpython-312.pyc deleted file mode 100644 index 69aec3d..0000000 Binary files a/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_module.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_service.cpython-312.pyc b/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_service.cpython-312.pyc deleted file mode 100644 index 434e69a..0000000 Binary files a/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_service.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_tools.cpython-312.pyc b/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_tools.cpython-312.pyc deleted file mode 100644 index 6429a99..0000000 Binary files a/scratch/test-flight/modules/flight_booking/__pycache__/flight_booking_tools.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-flight/modules/flight_booking/flight_booking_module.py b/scratch/test-flight/modules/flight_booking/flight_booking_module.py deleted file mode 100644 index be9c9ae..0000000 --- a/scratch/test-flight/modules/flight_booking/flight_booking_module.py +++ /dev/null @@ -1,13 +0,0 @@ -from nitrostack import module -from modules.flight_booking.flight_booking_tools import FlightBookingController -from modules.flight_booking.flight_booking_service import FlightBookingService - -@module( - name="flight_booking", - imports=[], - controllers=[FlightBookingController], - providers=[FlightBookingService], - exports=[FlightBookingService] -) -class FlightBookingModule: - pass diff --git a/scratch/test-flight/modules/flight_booking/flight_booking_service.py b/scratch/test-flight/modules/flight_booking/flight_booking_service.py deleted file mode 100644 index 57572d4..0000000 --- a/scratch/test-flight/modules/flight_booking/flight_booking_service.py +++ /dev/null @@ -1,35 +0,0 @@ -from nitrostack import injectable - -@injectable(deps=[]) -class FlightBookingService: - def __init__(self): - # Mock flight schedule - self.flights = { - "FL101": {"from": "NYC", "to": "LON", "date": "2026-07-01", "price": 450.0}, - "FL202": {"from": "PAR", "to": "TOK", "date": "2026-07-02", "price": 850.0}, - "FL303": {"from": "LAX", "to": "NYC", "date": "2026-07-03", "price": 200.0} - } - self.bookings = {} - self.booking_counter = 5000 - - def list_flights(self) -> dict: - return self.flights - - def book_flight(self, flight_id: str, passenger_name: str) -> dict: - if flight_id not in self.flights: - return {"status": "error", "message": f"Flight {flight_id} not found."} - - self.booking_counter += 1 - booking_id = f"BKG-{self.booking_counter}" - - self.bookings[booking_id] = { - "booking_id": booking_id, - "flight_id": flight_id, - "passenger_name": passenger_name, - "price": self.flights[flight_id]["price"], - "status": "Confirmed" - } - return self.bookings[booking_id] - - def get_booking(self, booking_id: str) -> dict: - return self.bookings.get(booking_id, {"status": "error", "message": f"Booking {booking_id} not found."}) diff --git a/scratch/test-flight/modules/flight_booking/flight_booking_tools.py b/scratch/test-flight/modules/flight_booking/flight_booking_tools.py deleted file mode 100644 index 2068259..0000000 --- a/scratch/test-flight/modules/flight_booking/flight_booking_tools.py +++ /dev/null @@ -1,49 +0,0 @@ -from nitrostack import injectable, tool, use_guards, OAuthGuard, ExecutionContext -from modules.flight_booking.flight_booking_service import FlightBookingService -from pydantic import BaseModel, Field - -class SearchFlightsInput(BaseModel): - pass - -class BookFlightInput(BaseModel): - flight_id: str = Field(description="The flight identifier (e.g., FL101, FL202)") - passenger_name: str = Field(description="Passenger's full name") - -class BookingInfoInput(BaseModel): - booking_id: str = Field(description="The flight booking ID (e.g., BKG-5001)") - -@injectable(deps=[FlightBookingService]) -class FlightBookingController: - def __init__(self, service: FlightBookingService): - self.service = service - - @tool( - name="search_flights", - description="Search for available flights and schedules", - input_schema=SearchFlightsInput - ) - async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict: - context.logger.info("Searching available flights...") - return {"flights": self.service.list_flights()} - - @tool( - name="book_flight", - description="Book a flight ticket. Requires OAuth authentication.", - input_schema=BookFlightInput - ) - @use_guards(OAuthGuard) - async def book_flight(self, input: BookFlightInput, context: ExecutionContext) -> dict: - # OAuthGuard validates access token and populates context.auth - user_id = getattr(context.auth, "subject", "unknown-user") - context.logger.info(f"Booking flight {input.flight_id} for user {user_id}") - return self.service.book_flight(input.flight_id, input.passenger_name) - - @tool( - name="get_booking_details", - description="Get booking receipt and details. Requires OAuth authentication.", - input_schema=BookingInfoInput - ) - @use_guards(OAuthGuard) - async def get_booking_details(self, input: BookingInfoInput, context: ExecutionContext) -> dict: - context.logger.info(f"Retrieving details for booking {input.booking_id}") - return self.service.get_booking(input.booking_id) diff --git a/scratch/test-food/__pycache__/app_module.cpython-312.pyc b/scratch/test-food/__pycache__/app_module.cpython-312.pyc deleted file mode 100644 index ae7ed59..0000000 Binary files a/scratch/test-food/__pycache__/app_module.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-food/app_module.py b/scratch/test-food/app_module.py deleted file mode 100644 index f0c130a..0000000 --- a/scratch/test-food/app_module.py +++ /dev/null @@ -1,12 +0,0 @@ -from nitrostack import module -from modules.food_delivery.food_delivery_module import FoodDeliveryModule - -@module( - name="app", - imports=[FoodDeliveryModule], - controllers=[], - providers=[], - exports=[] -) -class AppModule: - pass diff --git a/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_module.cpython-312.pyc b/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_module.cpython-312.pyc deleted file mode 100644 index 07ad91d..0000000 Binary files a/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_module.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_service.cpython-312.pyc b/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_service.cpython-312.pyc deleted file mode 100644 index 319e813..0000000 Binary files a/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_service.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_tools.cpython-312.pyc b/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_tools.cpython-312.pyc deleted file mode 100644 index 2faf157..0000000 Binary files a/scratch/test-food/modules/food_delivery/__pycache__/food_delivery_tools.cpython-312.pyc and /dev/null differ diff --git a/scratch/test-food/modules/food_delivery/food_delivery_module.py b/scratch/test-food/modules/food_delivery/food_delivery_module.py deleted file mode 100644 index f8f1431..0000000 --- a/scratch/test-food/modules/food_delivery/food_delivery_module.py +++ /dev/null @@ -1,13 +0,0 @@ -from nitrostack import module -from modules.food_delivery.food_delivery_tools import FoodDeliveryController -from modules.food_delivery.food_delivery_service import FoodDeliveryService - -@module( - name="food_delivery", - imports=[], - controllers=[FoodDeliveryController], - providers=[FoodDeliveryService], - exports=[FoodDeliveryService] -) -class FoodDeliveryModule: - pass diff --git a/scratch/test-food/modules/food_delivery/food_delivery_service.py b/scratch/test-food/modules/food_delivery/food_delivery_service.py deleted file mode 100644 index f2eddaf..0000000 --- a/scratch/test-food/modules/food_delivery/food_delivery_service.py +++ /dev/null @@ -1,41 +0,0 @@ -from nitrostack import injectable - -@injectable(deps=[]) -class FoodDeliveryService: - def __init__(self): - # In-memory database of items and orders - self.menu = { - "pizza": {"price": 12.99, "prep_time": 15}, - "burger": {"price": 8.99, "prep_time": 10}, - "salad": {"price": 7.49, "prep_time": 5}, - "sushi": {"price": 15.99, "prep_time": 20} - } - self.orders = {} - self.order_counter = 1000 - - def get_menu(self): - return self.menu - - def place_order(self, item: str, quantity: int) -> dict: - item_lower = item.lower() - if item_lower not in self.menu: - return {"status": "error", "message": f"Item '{item}' not found in the menu."} - - self.order_counter += 1 - order_id = f"ORDER-{self.order_counter}" - - price = self.menu[item_lower]["price"] * quantity - prep_time = self.menu[item_lower]["prep_time"] - - self.orders[order_id] = { - "order_id": order_id, - "item": item_lower, - "quantity": quantity, - "total_price": round(price, 2), - "status": "Preparing", - "time_remaining": prep_time - } - return self.orders[order_id] - - def get_order_status(self, order_id: str) -> dict: - return self.orders.get(order_id, {"status": "error", "message": f"Order {order_id} not found."}) diff --git a/scratch/test-food/modules/food_delivery/food_delivery_tools.py b/scratch/test-food/modules/food_delivery/food_delivery_tools.py deleted file mode 100644 index 46efa3f..0000000 --- a/scratch/test-food/modules/food_delivery/food_delivery_tools.py +++ /dev/null @@ -1,45 +0,0 @@ -from nitrostack import injectable, tool, ExecutionContext -from modules.food_delivery.food_delivery_service import FoodDeliveryService -from pydantic import BaseModel, Field - -class ViewMenuInput(BaseModel): - pass - -class PlaceOrderInput(BaseModel): - item: str = Field(description="The food item to order (e.g., pizza, burger, salad, sushi)") - quantity: int = Field(default=1, description="Number of items to order") - -class OrderStatusInput(BaseModel): - order_id: str = Field(description="The ID of the order to track (e.g., ORDER-1001)") - -@injectable(deps=[FoodDeliveryService]) -class FoodDeliveryController: - def __init__(self, service: FoodDeliveryService): - self.service = service - - @tool( - name="view_menu", - description="View the menu and list available food items and prices", - input_schema=ViewMenuInput - ) - async def view_menu(self, input: ViewMenuInput, context: ExecutionContext) -> dict: - context.logger.info("Fetching food delivery menu...") - return {"menu": self.service.get_menu()} - - @tool( - name="place_order", - description="Place a food delivery order for a menu item", - input_schema=PlaceOrderInput - ) - async def place_order(self, input: PlaceOrderInput, context: ExecutionContext) -> dict: - context.logger.info(f"Placing order for {input.quantity}x {input.item}") - return self.service.place_order(input.item, input.quantity) - - @tool( - name="track_order", - description="Track the status of an existing food delivery order", - input_schema=OrderStatusInput - ) - async def track_order(self, input: OrderStatusInput, context: ExecutionContext) -> dict: - context.logger.info(f"Tracking order status for {input.order_id}") - return self.service.get_order_status(input.order_id) diff --git a/scratch/test_mcp_init_direct.py b/scratch/test_mcp_init_direct.py deleted file mode 100644 index 8a919ef..0000000 --- a/scratch/test_mcp_init_direct.py +++ /dev/null @@ -1,13 +0,0 @@ -from mcp.server.lowlevel.server import Server -import mcp.types as types - -server = Server("test") - -async def my_handler(notification): - print("Handshake completed!") - -server.notification_handlers[types.InitializedNotification] = my_handler - -print("Registered notification handlers:") -for k, v in server.notification_handlers.items(): - print(k, v) diff --git a/test_custom_file.log b/test_custom_file.log deleted file mode 100644 index e69de29..0000000 diff --git a/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc index 0e0f14c..51d1b21 100644 Binary files a/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc and b/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_basic.cpython-312.pyc b/tests/__pycache__/test_basic.cpython-312.pyc deleted file mode 100644 index bf324f2..0000000 Binary files a/tests/__pycache__/test_basic.cpython-312.pyc and /dev/null differ diff --git a/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..cd8dfb8 Binary files /dev/null and b/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..a575ff8 Binary files /dev/null and b/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..bb3738a Binary files /dev/null and b/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc index 124c284..505d942 100644 Binary files a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc and b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_tasks.cpython-312.pyc b/tests/__pycache__/test_tasks.cpython-312.pyc deleted file mode 100644 index bb47473..0000000 Binary files a/tests/__pycache__/test_tasks.cpython-312.pyc and /dev/null differ diff --git a/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..e1f1730 Binary files /dev/null and b/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_initial_tool.py b/tests/test_initial_tool.py index cabd998..d105d98 100644 --- a/tests/test_initial_tool.py +++ b/tests/test_initial_tool.py @@ -37,7 +37,7 @@ async def on_init(self, input: EmptyInput, context: ExecutionContext) -> str: class TestInitialModule: pass -async def test_initial_tool_hook(): +async def _test_initial_tool_hook(): print("Testing @initial_tool auto-call hook...") # Create testing harness @@ -65,5 +65,8 @@ async def test_initial_tool_hook(): assert ExecutionState.called is True print("Test passed! @initial_tool was automatically called when InitializedNotification arrived.") +def test_initial_tool_hook(): + asyncio.run(_test_initial_tool_hook()) + if __name__ == "__main__": - asyncio.run(test_initial_tool_hook()) + test_initial_tool_hook() diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 36292ea..548da51 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -9,7 +9,7 @@ from nitrostack import DIContainer, ExecutionContext, OAuthGuard from nitrostack.auth.oauth import OAuthModule, OAuthService -async def test_oauth_guard_validation(): +async def _test_oauth_guard_validation(): print("Testing OAuthGuard and OAuth 2.1 validation flow...") # 1. Setup DIContainer and OAuthModule @@ -93,5 +93,8 @@ async def test_oauth_guard_validation(): print("Success! OAuthGuard and OAuth 2.1 token validations work perfectly.") +def test_oauth_guard_validation(): + asyncio.run(_test_oauth_guard_validation()) + if __name__ == "__main__": - asyncio.run(test_oauth_guard_validation()) + test_oauth_guard_validation() diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 520e7b7..25ade6e 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -41,7 +41,7 @@ async def delayed_tool(self, input: AsyncTaskInput, context: ExecutionContext) - class TestTasksModule: pass -async def test_all_tasks(): +async def _test_all_tasks(): print("Initializing NitroTestingModule with TestTasksModule...") harness = await NitroTestingModule.create(TestTasksModule) @@ -172,5 +172,8 @@ async def test_all_tasks(): print("\nAll task registry and protocol tests passed successfully!") +def test_all_tasks(): + asyncio.run(_test_all_tasks()) + if __name__ == "__main__": - asyncio.run(test_all_tasks()) + test_all_tasks() diff --git a/tests/test_widget_metadata.py b/tests/test_widget_metadata.py new file mode 100644 index 0000000..1860c60 --- /dev/null +++ b/tests/test_widget_metadata.py @@ -0,0 +1,69 @@ +import asyncio +import os +import sys + +# Ensure parent directory is in sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import injectable, tool, widget, module, ExecutionContext +from nitrostack.testing import NitroTestingModule +from pydantic import BaseModel + +class DummyInput(BaseModel): + pass + +@injectable() +class WidgetController: + @tool( + name="widget_tool", + description="A tool with a widget", + input_schema=DummyInput + ) + @widget("my-custom-widget-route") + async def widget_tool(self, input: DummyInput, context: ExecutionContext) -> dict: + return {"status": "ok"} + +@module( + name="widget_test", + controllers=[WidgetController] +) +class WidgetTestModule: + pass + +async def main(): + print("Testing widget decorator metadata mapping...") + + # 1. Initialize test harness + harness = await NitroTestingModule.create(WidgetTestModule) + + # 2. Extract tools from FastMCP server + tools = await harness.app.mcp_server.list_tools() + + # Find our tool + target_tool = None + for t in tools: + if t.name == "widget_tool": + target_tool = t + break + + assert target_tool is not None, "widget_tool was not registered" + + print("Registered tool representation:", target_tool) + + # Verify metadata fields are present + meta = getattr(target_tool, "meta", None) + if meta is None: + meta = getattr(target_tool, "_meta", {}) + + assert meta is not None, "Tool metadata is missing" + print("Tool metadata:", meta) + + # Check that widget fields are populated in metadata + assert meta.get("ui/template") == "my-custom-widget-route" + assert meta.get("openai/outputTemplate") == "my-custom-widget-route" + assert meta.get("ui") == {"resourceUri": "my-custom-widget-route"} + + print("Success! Widget metadata is correctly mapped and verified in the MCP Tool specification.") + +if __name__ == "__main__": + asyncio.run(main())